diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000000..5abd839887 --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,8 @@ +--- +engines: + duplication: + enabled: true + exclude_paths: + - elide-core/src/test/java/com/yahoo/elide/graphql/* +exclude_paths: + - elide-core/src/test/java/com/yahoo/elide/graphql/* \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000000..ad35a4bc5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,41 @@ + + +## Expected Behavior + + + + +## Current Behavior + + + + +## Possible Solution + + + +## Steps to Reproduce (for bugs) + + +1. +2. +3. +4. + +## Context + + + + +## Your Environment + +* Elide version used: +* Environment name and version (Java 1.8.0_152): +* Operating System and version: +* Link to your project: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..97c5b4e4b8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +Resolves # (if appropriate) + +## Description + + + + +## Motivation and Context + + +## How Has This Been Tested? + + + + +## Screenshots (if appropriate): + + +## License +I confirm that this contribution is made under an Apache 2.0 license and that I have the authority necessary to make this contribution on behalf of its copyright owner. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..990c4fb904 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: +- package-ecosystem: maven + directory: "/" + schedule: + interval: weekly + time: "13:00" + open-pull-requests-limit: 10 + ignore: + - dependency-name: org.hibernate.validator:hibernate-validator + versions: + - 7.0.0.Final + - dependency-name: com.graphql-java:graphql-java + versions: + - "16.1" + - dependency-name: org.glassfish.jersey.inject:jersey-hk2 + versions: + - 3.0.0 + - dependency-name: org.glassfish.jersey.containers:jersey-container-servlet + versions: + - 3.0.0 diff --git a/.gitignore b/.gitignore index 7eca438886..c618f5bbe7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,13 @@ test-output/ *.iml bin dependency-reduced-pom.xml +*.jar +*.class +*tags +**.swp +.project +*/.project +*/*/.project +*.factorypath +*.vscode +.DS_Store diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 396f45c477..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,37 +0,0 @@ ---- - language: java - sudo: false - jdk: - - oraclejdk8 - addons: - apt: - packages: - - libaio-dev - cache: - directories: - - "~/.m2" - env: - global: - - secure: VV+uJlBgXRCUSj3+khXnKvbrB0bxtay3yovKAchTT83Y6+iXUAgp3b7EA1QjT0l3Hz88Y+EAVuiZqwzFRidbZwbgApaxYMJKWjHTmr+VsXXJp29J1tyGtXR1K+DUG0HO/jiRCxMVythB68BnNMIXfprpmsLFpXhKMPf41fzMaAtqSYrPdJkNvEzBDhu5hIPG9apyEcpIZNhtfwnk18DntyGFC3OVAKzEma51s0tS6NeXiZNzTvc9j0d4Fr8vyWGclAp1XKMjWzpvfRyB2EFto076V+8tJ5E3czJjJs12SDwOvEdzYeCpGk3VWboiz9DuZn41IdTLYy89OmcIooZQHViMwiatcqqHREEbuuUWZLYMEh1bUHZHRWQf2AnUScDfJB1QUKVR8mbkSr5DXto4FIsHpqofoBloDveaXkWC735DqOPwbIwe2owGZfkQv19vPpVvF3e2LyFn1vf8blR3wFBItKKZyvBTbrIVM/YxuTDfswDVlhEgHTeet0TQSy4yEoMxGTHsfk8Ykc2B0JDheWH5zhhZBdozmV5TFbM50PXjLqfqWVDD+WqzGVer1Sokfv6MMERoWKvb/LEyKO4Eq3aHgXyy2GoRQHfUh9qOOvBnNpHbtacL+NzfggLZ1Ut97QIbEoWb3VPx6lbNKxiiC4DbwR7nnc92zNJM5YU6Dog= - - secure: SSqm3fI2slK6qcmfeRiWvQJ8kNd5VQD6wSkFM5NMCo7Uvo3peNNwCDiTpWMN6nNuM4LSJJhgGwDLZeIlRF4i74xgWpDznjZ4AoqgG1QeIjg/4Xdy+szz0+BiTdUhQnRsaKHfUoky4wm+Jrix1rYgz/eED6uiOHmgLWywD4+BEBHN37N5N6/vT7ELkxi/jzXWOunMA12Y6F+LFcw4A87qM94iFws94C0BEqfWRqaXYPZoQ7v5Yw7TilJuPGiL6pcdWyh5ObfAhIbHhhxmIvaMFWV7HGoB1rC2T/E/GQynKU/l1uQHTf/rNK70uUTCKz3usl28AnVsnyhjg7+Ivk22Z0pSnZtLw1TJT5prPdX+Cas8NEElk6zSS/h3lMmQuGZk8T6FmsqkDx3tkFiJqgxvbgGLZBMgYdm87OCzrM55ZTlxBOYErQQgFrevJ2oJV98Pb4DNCPx5uu/CVCbR+d1kZ6b91rabzjl+a3yiz10vnaO00dyW1gR1P0KHZiRactdds2jLsxBhr3MFDymZD7W+WhuI4RtP2/bsQQ1dnLIvWm+JYmDvr09/7k1uX7WCm9AkMb0u1gW19WYQT1Qct3hFfvKIbgv3gTNJz2+Alu3mqeIpSkmtdtGjVMij3/0OBdYBNC+BfaR4IQVnIbvyu0HmjtJgRji2TO/U7rCUKjOrm6c= - branches: - only: - - master - - "/^[0-9]+\\.[0-9]+\\.[0-9]+/" - # build steps - install: true - script: mvn clean verify # coveralls:report - after_success: - - test "${TRAVIS_PULL_REQUEST}" == "false" && mvn versioneye:update - - test "${TRAVIS_PULL_REQUEST}" == "false" && test "${TRAVIS_TAG}" != "" && mvn deploy --settings travis/settings.xml -# before_deploy: cat travis/bintray_template.json | sed s/\$\{TRAVIS_TAG\}/${TRAVIS_TAG}/g > travis/release.json -# deploy: -# on: -# tags: true -# provider: bintray -# file: travis/release.json -# user: -# secure: TvyOrjLtOBgaBt986RSuRhyUYwBEPS3d3ug3Y7Bd0rRIw32jL8yH3GcSz+pf2wSb80CIm5xTRuyin3NEYAKGo2X4y2PQx6FkXrxxa4T/RR8u5OyAm4JqRjfB3ZCkSU5agvUCRPXSoch6r1FYZ/NL/tian3kw/ajKDutXurpyXAPCfXmRiQIqGUwzty+znIlmLvpwtsJMcOpSy/gqNJNqHSoLHSXTNETTFoeLvbogLhfl0em88LuApWC4sZIQTMtHPssugNYagxFpjUg16PCLdX5f7HolG8TLf7JjO/W7tXOwXHOl8sePVEtzZogJGWKtA8C9ob02uyPerGyK+lPyFPwf/2OhX4eVcq7W9WCTqnU3gR/ihkRe6ssqhOceWu3uSm9iZ5k9LUFgDId3VaDI3Tq1DI6TFTrco1F2qGSChB0avCITDGKhfeI/oiHD4a4T9AVKx7uyw7pTlkpRFvZMzKXHlq5FWml6pz4vY9+d26O23KTfKqbUhaLvHqN8O77ExjsPDpozIJtyZP8dA0od2xemZSy03GXhLpd9hu5P8t8q+c01EGNgTK93PCMcxjQddIGuYC8H87ElfinJ9XlnmTP9QVRky40NZiTSWZf1bXHZoFMV4t7zzJqw6LqENp8jeYzewM0hnW9TDTXmZMcXgg1bouyRnMBO9aN9SJvO69g= -# key: -# secure: ea9647jI7moJIjttA9YSCwKI0oXt53NiRBeN4+mngNT34u/8oCMpks2zXoc6RecYHfnX44gz9QO9F6c2PMn0alkGzZHZk46VhNx/kV0jXksIBs1WQK8gAR0hqkOpRgETUi+E9Gv/oZuA+ZSIr42YZ9X+DD2VwFE5S357GQhXeyZ+Z+OT2cr9bRxqMBjC7mbNUZuoCJMORuqQqbFIOkEIc0cHK8WZ9s8ZkMHlekr6cYdpL3WqPCjxs6j6fn7YfvGLgkspiM5WpNunQLbM9sLQt8O4lMNNPcPYJzMM4/cuXQxZ+JOARs5M7YJTjFkcHEufGYiqc6Ha5zpnqCo+d2QAc9atX+lMqr4TO7KuQjL+k6qqtzYRDg1QDZvz+lapEb5n6DPDNG92D8OQZMq4rYiRYqm9wOLQZ6TB3by9ClZJQZZT1+gsayCR8IHnp7LzaLFzQVOXCpht2pMx1Rk9z6lIvy3D2sCl7B8jIik9YoLr/86U4mg6kp/B9+3jtIC3S4Bxznm5v2ZMbzQlngm6z/26w86pJ8UYhK/nAh1TDYPZEWmyU0iScXAI8ocrhdPXKsd/grdEXnb+Ka7ct9EldINztDkoOpMcXvf2ZA1WdCevMtmelkaiW9nNenN94537JP+24RYNNdhZUg80X1B+Q8Mo1+QKcmhIhw+6BMqY4WVrghQ= -# after_deploy: echo "Pending" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2192987531..63cb0ad52f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,26 @@ -# How to Contribute +# How to contribute +First, thanks for taking the time to contribute to our project! There are many ways you can help out. -When submitting a pull request (PR), please use the following guidelines: +### Questions + +If you have a question that needs an answer, [create an issue](https://github.com/yahoo/elide/issues/new), and label it as a question. + +### Issues for bugs or feature requests + +If you encounter any bugs in the code, or want to request a new feature or enhancement, please [create an issue](https://github.com/yahoo/elide/issues/new) to report it. Kindly add a label to indicate what type of issue it is. + +### Contribute Code +We welcome your pull requests for bug fixes. To implement something new, please create an issue first so we can discuss it together. + +***Creating a Pull Request*** +Please follow [best practices](https://github.com/trein/dev-best-practices/wiki/Git-Commit-Best-Practices) for creating git commits. In addition: - Make sure your code respects existing formatting conventions. In general, follow the same coding style as the code that you are modifying. If you are using IntelliJ, you can import our code style settings xml: - [elide-intellij-codestyle.xml](https://github.com/yahoo/elide/raw/master/elide-intellij-codestyle.xml). -- Do add/update documentation appropriately for the change you are making. + [elide-intellij-codestyle.xml](https://github.com/yahoo/elide/raw/master/elide-intellij-codestyle.xml) + or [elide-eclipse.importorder](https://github.com/yahoo/elide/raw/master/elide-eclipse.importorder). +- Do add/update [documentation](https://github.com/yahoo/elide-doc) appropriately for the change you are making. - Bugfixes should include a unit test or integration test reproducing the issue. - Do not use author tags/information in the code. - Always include license header on each file your create. See [this example](https://github.com/yahoo/elide/blob/master/elide-core/src/main/java/com/yahoo/elide/Elide.java) @@ -16,3 +30,14 @@ When submitting a pull request (PR), please use the following guidelines: Each commit should compile on its own and ideally pass tests. - Keep formatting changes in separate commits to make code reviews easier and distinguish them from actual code changes. + +When your code is ready to be submitted, [submit a pull request](https://help.github.com/articles/creating-a-pull-request/) to begin the code review process. + +We only seek to accept code that you are authorized to contribute to the project. We have added a pull request template on our projects so that your contributions are made with the following confirmation: + +> I confirm that this contribution is made under the terms of the license found in the root directory of this repository's source tree and that I have the authority necessary to make this contribution on behalf of its copyright owner. + +## Code of Conduct + +We encourage inclusive and professional interactions on our project. We welcome everyone to open an issue, improve the documentation, report bug or submit a pull request. By participating in this project, you agree to abide by the [Yahoo Code of Conduct](Code-Of-Conduct.md). If you feel there is a conduct issue related to this project, please raise it per the Code of Conduct process and we will address it. + diff --git a/Code-Of-Conduct.md b/Code-Of-Conduct.md new file mode 100644 index 0000000000..0d66153d1f --- /dev/null +++ b/Code-Of-Conduct.md @@ -0,0 +1,54 @@ +# Yahoo Open Source Code of Conduct + +## Summary +This Code of Conduct is our way to encourage good behavior and discourage bad behavior in our open source community. We invite participation from many people to bring different perspectives to support this project. We pledge to do our part to foster a welcoming and professional environment free of harassment. We expect participants to communicate professionally and thoughtfully during their involvement with this project. + +Participants may lose their good standing by engaging in misconduct. For example: insulting, threatening, or conveying unwelcome sexual content. We ask participants who observe conduct issues to report the incident directly to the project's Response Team at opensource-conduct@yahooinc.com. Yahoo will assign a respondent to address the issue. We may remove harassers from this project. + +This code does not replace the terms of service or acceptable use policies of the websites used to support this project. We acknowledge that participants may be subject to additional conduct terms based on their employment which may govern their online expressions. + +## Details +This Code of Conduct makes our expectations of participants in this community explicit. +* We forbid harassment and abusive speech within this community. +* We request participants to report misconduct to the project’s Response Team. +* We urge participants to refrain from using discussion forums to play out a fight. + +### Expected Behaviors +We expect participants in this community to conduct themselves professionally. Since our primary mode of communication is text on an online forum (e.g. issues, pull requests, comments, emails, or chats) devoid of vocal tone, gestures, or other context that is often vital to understanding, it is important that participants are attentive to their interaction style. + +* **Assume positive intent.** We ask community members to assume positive intent on the part of other people’s communications. We may disagree on details, but we expect all suggestions to be supportive of the community goals. +* **Respect participants.** We expect participants will occasionally disagree. Even if we reject an idea, we welcome everyone’s participation. Open Source projects are learning experiences. Ask, explore, challenge, and then respectfully assert if you agree or disagree. If your idea is rejected, be more persuasive not bitter. +* **Welcoming to new members.** New members bring new perspectives. Some may raise questions that have been addressed before. Kindly point them to existing discussions. Everyone is new to every project once. +* **Be kind to beginners.** Beginners use open source projects to get experience. They might not be talented coders yet, and projects should not accept poor quality code. But we were all beginners once, and we need to engage kindly. +* **Consider your impact on others.** Your work will be used by others, and you depend on the work of others. We expect community members to be considerate and establish a balance their self-interest with communal interest. +* **Use words carefully.** We may not understand intent when you say something ironic. Poe’s Law suggests that without an emoticon people will misinterpret sarcasm. We ask community members to communicate plainly. +* **Leave with class.** When you wish to resign from participating in this project for any reason, you are free to fork the code and create a competitive project. Open Source explicitly allows this. Your exit should not be dramatic or bitter. + +### Unacceptable Behaviors +Participants remain in good standing when they do not engage in misconduct or harassment. To elaborate: +* **Don't be a bigot.** Calling out project members by their identity or background in a negative or insulting manner. This includes, but is not limited to, slurs or insinuations related to protected or suspect classes e.g. race, color, citizenship, national origin, political belief, religion, sexual orientation, gender identity and expression, age, size, culture, ethnicity, genetic features, language, profession, national minority statue, mental or physical ability. +* **Don't insult.** Insulting remarks about a person’s lifestyle practices. +* **Don't dox.** Revealing private information about other participants without explicit permission. +* **Don't intimidate.** Threats of violence or intimidation of any project member. +* **Don't creep.** Unwanted sexual attention or content unsuited for the subject of this project. +* **Don't disrupt.** Sustained disruptions in a discussion. +* **Let us help.** Refusal to assist the Response Team to resolve an issue in the community. + +We do not list all forms of harassment, nor imply some forms of harassment are not worthy of action. Any participant who *feels* harassed or *observes* harassment, should report the incident. Victim of harassment should not address grievances in the public forum, as this often intensifies the problem. Report it, and let us address it off-line. + +### Reporting Issues +If you experience or witness misconduct, or have any other concerns about the conduct of members of this project, please report it by contacting our Response Team at opensource-conduct@yahooinc.com who will handle your report with discretion. Your report should include: +* Your preferred contact information. We cannot process anonymous reports. +* Names (real or usernames) of those involved in the incident. +* Your account of what occurred, and if the incident is ongoing. Please provide links to or transcripts of the publicly available records (e.g. a mailing list archive or a public IRC logger), so that we can review it. +* Any additional information that may be helpful to achieve resolution. + +After filing a report, a representative will contact you directly to review the incident and ask additional questions. If a member of the Yahoo Response Team is named in an incident report, that member will be recused from handling your incident. If the complaint originates from a member of the Response Team, it will be addressed by a different member of the Response Team. We will consider reports to be confidential for the purpose of protecting victims of abuse. + +### Scope +Yahoo will assign a Response Team member with admin rights on the project and legal rights on the project copyright. The Response Team is empowered to restrict some privileges to the project as needed. Since this project is governed by an open source license, any participant may fork the code under the terms of the project license. The Response Team’s goal is to preserve the project if possible, and will restrict or remove participation from those who disrupt the project. + +This code does not replace the terms of service or acceptable use policies that are provided by the websites used to support this community. Nor does this code apply to communications or actions that take place outside of the context of this community. Many participants in this project are also subject to codes of conduct based on their employment. This code is a social-contract that informs participants of our social expectations. It is not a terms of service or legal contract. + +## License and Acknowledgment. +This text is shared under the [CC-BY-4.0 license](https://creativecommons.org/licenses/by/4.0/). This code is based on a study conducted by the [TODO Group](https://todogroup.org/) of many codes used in the open source community. If you have feedback about this code, contact our Response Team at the address listed above. diff --git a/Makefile b/Makefile deleted file mode 100644 index 889b12121e..0000000000 --- a/Makefile +++ /dev/null @@ -1,2 +0,0 @@ -check-version: - mvn versions:display-dependency-updates versions:display-plugin-updates versions:display-property-updates diff --git a/README.md b/README.md index 06fed79b78..09b01ee600 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,235 @@ -[![Gitter](https://badges.gitter.im/yahoo/elide.svg)](https://gitter.im/yahoo/elide?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Build Status](https://travis-ci.org/yahoo/elide.svg?branch=master)](https://travis-ci.org/yahoo/elide) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.yahoo.elide/elide-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.yahoo.elide/elide-core) +# Elide -![Elide Logo](https://cdn.rawgit.com/yahoo/elide/master/elide.svg) +> _Opinionated APIs for web & mobile applications._ -##What Is Elide? +![Elide Logo](elide-logo.svg) -Elide is a Java library that lets you stand up a [JSON API](http://jsonapi.org) web service with minimal effort starting from a [JPA annotated data model](https://en.wikipedia.org/wiki/Java_Persistence_API). -Elide is designed to quickly build and deploy **production quality** web services that expose data models as services. Elide provides: - 1. **Access** to JPA entities via JSON API CRUD operations. Entities can be explicitly included or excluded via annotations. - 2. **Patch Extension** Elide supports the [JSON API Patch extension](http://jsonapi.org/extensions/jsonpatch/) allowing multiple create, edit, and delete operations in a single request. - 3. **Atomic Requests** All requests to the library (including the patch extension) can be embedded in transactions to ensure operational integrity. - 4. **Authorization** All operations on entities and their fields can be assigned custom permission checks limiting who has access to your data. - 5. **Audit** Logging can be customized for any operation on any entity. - 6. **Extension** Elide allows the ability to customize business logic for any CRUD operation on the model. Any persistence backend can be skinned with JSON-API by wiring in a JPA provider or by implementing a custom `DataStore`. - 7. **Test** Elide includes a test framework that explores the full surface of the API looking for security vulnerabilities. - 8. **Client API** Elide is developed in conjunction with a Javascript client library that insulates developers from changes to the specification. Alternatively, Elide can be used with any [JSON API client library](http://jsonapi.org/implementations/). +[![Discord](https://img.shields.io/discord/869678398241398854)](https://discord.com/widget?id=869678398241398854&theme=dark) +[![Build Status](https://cd.screwdriver.cd/pipelines/6103/badge)](https://cd.screwdriver.cd/pipelines/6103) +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.yahoo.elide/elide-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.yahoo.elide/elide-core) +[![Coverage Status](https://coveralls.io/repos/github/yahoo/elide/badge.svg?branch=master)](https://coveralls.io/github/yahoo/elide?branch=master) +[![Code Quality: Java](https://img.shields.io/lgtm/grade/java/g/yahoo/elide.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/yahoo/elide/context:java) +[![Total Alerts](https://img.shields.io/lgtm/alerts/g/yahoo/elide.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/yahoo/elide/alerts) +[![Mentioned in Awesome Java](https://awesome.re/mentioned-badge.svg)](https://github.com/akullpp/awesome-java) +[![Mentioned in Awesome GraphQL](https://awesome.re/mentioned-badge.svg)](https://github.com/chentsulin/awesome-graphql) -##Documentation +*Read this in other languages: [中文](translations/zh/README.md).* -More information about Elide can be found at [elide.io](http://elide.io/). +## Table of Contents -## Elide on Maven +- [Background](#background) +- [Documentation](#documentation) +- [Install](#install) +- [Usage](#usage) +- [Security](#security) +- [Contribute](#contribute) +- [License](#license) -To integrate Elide into your project, simply include elide-core into your project's pom.xml: +## Background + +[Elide](https://elide.io/) is a Java library that lets you setup model driven [GraphQL](http://graphql.org) or [JSON API](http://jsonapi.org) web service with minimal effort. Elide supports two variants of APIs: + +1. A CRUD (Create, Read, Update, Delete) API for reading and manipulating models. +2. An analytic API for aggregating measures over zero or more model attributes. + +Elide supports a number of features: + +### Security Comes Standard +Control access to fields and entities through a declarative, intuitive permission syntax. + +### Mobile Friendly APIs +JSON-API & GraphQL lets developers fetch entire object graphs in a single round trip. Only requested elements of the data model are returned. +Our opinionated approach for mutations addresses common application scenarios: +* Create a new object and add it to an existing collection in the same operation. +* Create a set of related, composite objects (a subgraph) and connect it to an existing, persisted graph. +* Differentiate between deleting an object vs disassociating an object from a relationship (but not deleting it). +* Change the composition of a relationship to something different. +* Reference a newly created object inside other mutation operations. + +Filtering, sorting, pagination, and text search are supported out of the box. + +### Atomicity For Complex Writes +Elide supports multiple data model mutations in a single request in either JSON-API or GraphQL. Create objects, add them to relationships, modify or delete together in a single atomic request. + +## Analytic Query Support +Elide supports analytic queries against models crafted with its powerful semantic layer. Elide APIs work natively with [Yavin](https://github.com/yahoo/yavin) to visualize, explore, and report on your data. + +### Schema Introspection +Explore, understand, and compose queries against your Elide API through generated Swagger documentation or GraphQL schema. + +### Customize +Customize the behavior of data model operations with computed attributes, data validation annotations, and request lifecycle hooks. + +### Storage Agnostic +Elide is agnostic to your particular persistence strategy. Use an ORM or provide your own implementation of a data store. + +## Documentation + +More information about Elide can be found at [elide.io](https://elide.io/). + +## Install + +To try out an Elide example service, check out this [Spring boot example project](https://github.com/yahoo/elide-spring-boot-example). + +Alternatively, use [elide-standalone](https://github.com/yahoo/elide/tree/master/elide-standalone) which allows you to quickly setup a local instance of Elide running inside an embedded Jetty application. + +## Usage + +### For CRUD APIs + +The simplest way to use Elide is by leveraging [JPA](https://en.wikipedia.org/wiki/Java_Persistence_API) to map your Elide models to persistence: + +The models should represent the domain model of your web service: + +```java +@Entity +public class Book { + + @Id + private Integer id; + + private String title; + + @ManyToMany(mappedBy = "books") + private Set authors; +} +``` + +Add Elide annotations to both expose your models through the web service and define security policies for access: + +```java +@Entity +@Include(rootLevel = true) +@ReadPermission("Everyone") +@CreatePermission("Admin OR Publisher") +@DeletePermission("None") +@UpdatePermission("None") +public class Book { + + @Id + private Integer id; + + @UpdatePermission("Admin OR Publisher") + private String title; + + @ManyToMany(mappedBy = "books") + private Set authors; +} +``` + +Add Lifecycle hooks to your models to embed custom business logic that execute inline with CRUD operations through the web service: + +```java +@Entity +@Include(rootLevel = true) +@ReadPermission("Everyone") +@CreatePermission("Admin OR Publisher") +@DeletePermission("None") +@UpdatePermission("None") +@LifeCycleHookBinding(operation = UPDATE, hook = BookCreationHook.class, phase = PRECOMMIT) +public class Book { + + @Id + private Integer id; + + @UpdatePermission("Admin OR Publisher") + private String title; + + @ManyToMany(mappedBy = "books") + private Set authors; +} + +public class BookCreationHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + Book book, + RequestScope requestScope, + Optional changes) { + //Do something + } +} -```xml - - - com.yahoo.elide - elide-core - ``` -Additionally, if you do not plan to write your own data store, select the appropriate data store for your setup and include it as well. For instance, if you plan on using the "in-memory database" (not recommended for production use) then you would add the following: +Map expressions to security functions or predicates that get pushed to the persistence layer: + +```java + @SecurityCheck("Admin") + public static class IsAdminUser extends UserCheck { + @Override + public boolean ok(User user) { + return isUserInRole(user, UserRole.admin); + } + } +``` + +To expose and query these models, follow the steps documented in [the getting started guide](https://elide.io/pages/guide/v5/01-start.html). + +For example API calls, look at: +1. [*JSON-API*](https://elide.io/pages/guide/v5/10-jsonapi.html) +2. [*GraphQL*](https://elide.io/pages/guide/v5/11-graphql.html) + +### For Analytic APIs -```xml - - com.yahoo.elide - elide-datastore-inmemorydb - +Analytic models including tables, measures, dimensions, and joins can be created either as POJOs or with a friendly HJSON configuration language: + +```hjson +{ + tables: [ + { + name: Orders + table: order_details + measures: [ + { + name: orderTotal + type: DECIMAL + definition: 'SUM({{$order_total}})' + } + ] + dimensions: [ + { + name: orderId + type: TEXT + definition: '{{$order_id}}' + } + ] + } + ] +} ``` -##License +More information on configuring or querying analytic models can be found [here](https://elide.io/pages/guide/v5/04-analytics.html). + +## Security + +Security is documented in depth [here](https://elide.io/pages/guide/v5/03-security.html). + +## Contribute +Please refer to [the contributing.md file](CONTRIBUTING.md) for information about how to get involved. We welcome issues, questions, and pull requests. + +If you are contributing to Elide using an IDE, such as IntelliJ, make sure to install the [Lombok](https://projectlombok.org/) plugin. + +Community chat is now on [discord](https://discord.com/widget?id=869678398241398854&theme=dark). +Legacy discussion is archived on [spectrum](https://spectrum.chat/elide). + +## License +This project is licensed under the terms of the [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0.html) open source license. +Please refer to [LICENSE](LICENSE.txt) for the full terms. + +## Articles +Intro to Elide video + +[![Intro to Elide](http://img.youtube.com/vi/WeFzseAKbzs/0.jpg)](http://www.youtube.com/watch?v=WeFzseAKbzs "Intro to Elide") + +[Create a JSON API REST Service With Spring Boot and Elide](https://dzone.com/articles/create-a-json-api-rest-service-with-spring-boot-an) + +[Custom Security With a Spring Boot/Elide Json API Server](https://dzone.com/articles/custom-security-with-a-spring-bootelide-json-api-s) + +[Logging Into a Spring Boot/Elide JSON API Server](https://dzone.com/articles/logging-into-a-spring-bootelide-json-api-server) + +[Securing a JSON API REST Service With Spring Boot and Elide](https://dzone.com/articles/securing-a-json-api-rest-service-with-spring-boot) + +[Creating Entities in a Spring Boot/Elide JSON API Server](https://dzone.com/articles/creating-entities-in-a-spring-bootelide-json-api-s) -The use and distribution terms for this software are covered by the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html). +[Updating and Deleting with a Spring Boot/Elide JSON API Server](https://dzone.com/articles/updating-and-deleting-with-a-spring-bootelide-json) diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000000..528cd6d06b --- /dev/null +++ b/changelog.md @@ -0,0 +1,1701 @@ +# Change Log + +## 6.1.3 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/1a644f6f5afec9f0de8dbf89a56006d9f5b3058a) Bump log4j-to-slf4j from 2.17.1 to 2.17.2 (#2560) + * [view commit](https://github.com/yahoo/elide/commit/d2c906859486a1837ebca6add7d7ec881380b9ea) Bump version.jackson from 2.12.5 to 2.13.1 (#2527) + * [view commit](https://github.com/yahoo/elide/commit/588b69f120c8e914da4becc3f8b83b4d8b71d92e) Update: Make `-` a valid TEXT value type character (#2565) + * [view commit](https://github.com/yahoo/elide/commit/5b7051da2a0cc9b97b2dd29c5b95a04a794a6901) Bump spring.boot.version from 2.6.3 to 2.6.5 (#2566) + * [view commit](https://github.com/yahoo/elide/commit/5e67e547d60483696508e2e90460eee21c391491) Fix graphiql comment bug and config store issues creating multiple files in a single request. (#2571) + * [view commit](https://github.com/yahoo/elide/commit/14ae1c4f31914f44ac3f347a80235947da3231f7) Bump jackson-databind from 2.13.2 to 2.13.2.1 (#2569) + * [view commit](https://github.com/yahoo/elide/commit/78c636ed19e57a98e84243689dcf6f14c054681d) Bump hibernate5.version from 5.6.5.Final to 5.6.7.Final (#2568) + * [view commit](https://github.com/yahoo/elide/commit/cf3bbfed579710352e4501487f7939a162a13c9c) Bump groovy.version from 3.0.9 to 3.0.10 (#2567) + * [view commit](https://github.com/yahoo/elide/commit/507ba6ced3ef2ef68c484541481f9c5b7499f342) Bump nexus-staging-maven-plugin from 1.6.11 to 1.6.12 (#2559) + * [view commit](https://github.com/yahoo/elide/commit/c7d44ce56eee1f0dced6afc1520fd93309614f6e) Bump spring-cloud-context from 3.1.0 to 3.1.1 (#2557) + +## 6.1.2 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/1355701e30f63ba7332e4b77efdf5166e21a3e61) Bump json-path from 2.6.0 to 2.7.0 (#2531) + * [view commit](https://github.com/yahoo/elide/commit/1d105705f533d40b55469f3b6836935995e982b5) Ignoring Elide models embedded in complex attributes (#2535) + * [view commit](https://github.com/yahoo/elide/commit/76a45fc895901fa212883e88a9be29fdad894b7c) Bump version.jetty from 9.4.44.v20210927 to 9.4.45.v20220203 (#2534) + * [view commit](https://github.com/yahoo/elide/commit/0d1be94716765581ab230b5b1d77906d3882b283) Bump jedis from 4.1.0 to 4.1.1 (#2530) + * [view commit](https://github.com/yahoo/elide/commit/73d2ce87b2af2a0dd2c4ad800030724d76f085e0) Bump mockito-core from 4.2.0 to 4.3.1 (#2518) + * [view commit](https://github.com/yahoo/elide/commit/7367573a0aba833d1e1415b3ce74088e7ad695f0) Bump mockito-junit-jupiter from 4.2.0 to 4.3.1 (#2517) + * [view commit](https://github.com/yahoo/elide/commit/72954a50f4f1e02cb257f9e7224491f1b3bef7a5) Bump spring.boot.version from 2.6.2 to 2.6.3 (#2510) + * [view commit](https://github.com/yahoo/elide/commit/8a0cc1d198eb36d2e5681c44143230a969745534) Bump spring-cloud-context from 3.0.4 to 3.1.0 (#2512) + * [view commit](https://github.com/yahoo/elide/commit/6409cb61e9555c6331f7e1b353456a0a11c51962) Bump classgraph from 4.8.138 to 4.8.139 (#2541) + * [view commit](https://github.com/yahoo/elide/commit/b2c48a1dad67b1dbf28c80c4a9286360ea52ec91) Bump metrics.version from 4.2.7 to 4.2.8 (#2540) + * [view commit](https://github.com/yahoo/elide/commit/cb3d4c05f60828757e239f75be13793d52841cbc) Bump maven-compiler-plugin from 3.9.0 to 3.10.0 (#2542) + * [view commit](https://github.com/yahoo/elide/commit/a91983785f2f7c87857d13e5e64a00dd45a6c7f9) Bump log4j-over-slf4j from 1.7.35 to 1.7.36 (#2547) + * [view commit](https://github.com/yahoo/elide/commit/d3377acdd7dd94f0919f305c3f19a5851ea1f342) Bump checkstyle from 9.2.1 to 9.3 (#2546) + * [view commit](https://github.com/yahoo/elide/commit/a7f654cd10b74d452a08e6a9c12cdf7239eb3acc) Bump swagger-core from 1.6.4 to 1.6.5 (#2549) + * [view commit](https://github.com/yahoo/elide/commit/43db0ba55f34e0052ac264fe95e676d4e3463d0d) Bump maven-javadoc-plugin from 3.3.1 to 3.3.2 (#2544) + * [view commit](https://github.com/yahoo/elide/commit/cc1855fee8f236ffbdd90c21a30c8b15fe5f6d59) Bump nexus-staging-maven-plugin from 1.6.8 to 1.6.11 (#2548) + * [view commit](https://github.com/yahoo/elide/commit/dfa92a6b48532ce43a5518eefb57df19d73385de) Bump micrometer-core from 1.8.2 to 1.8.3 (#2545) + * [view commit](https://github.com/yahoo/elide/commit/ecce96462be68acbf95f3ba73f0c8ba3d46ed9a1) Bump maven-site-plugin from 3.10.0 to 3.11.0 (#2543) + * [view commit](https://github.com/yahoo/elide/commit/8d70b62b4650b9c7c9d6dd7276306301dca5839d) Bump spring-websocket from 5.3.15 to 5.3.16 (#2553) + * [view commit](https://github.com/yahoo/elide/commit/4b31a018ec4abac13cf9238f78e585857c465195) Bump gson from 2.8.9 to 2.9.0 (#2552) + * [view commit](https://github.com/yahoo/elide/commit/3c5932522440ea4e0298e55bbb8eebfdd5a06ba6) Bump slf4j-api from 1.7.35 to 1.7.36 (#2550) + * [view commit](https://github.com/yahoo/elide/commit/69056c88fa6dbb13de0a667f5c50defcdc34093d) Bump spring-core from 5.3.15 to 5.3.16 (#2554) + +## 6.1.1 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/6646ce31a465a6648dcb74fb96aeb5c6d39a22bc) Redis Cache for Aggregation Store (#25 + * [view commit](https://github.com/yahoo/elide/commit/df5d023f43876264b63fd3becfbff57a4e889789) Redis Result Storage Engine (#2507) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/9c9d4ca1fa15047e24fffcf2a7e0e7a9a46e6791) Disabling subscription scanning throug +h application yaml in Spring. (#2521) + * [view commit](https://github.com/yahoo/elide/commit/c038599b4f4d1d7b9a688365e0157425daf19c5b) Bump h2 from 2.0.206 to 2.1.210 (#2508 +) + * [view commit](https://github.com/yahoo/elide/commit/3e69dabdd9479a8c070b811299ab535f5ba7a1db) Bump hibernate5.version from 5.6.1.Fin +al to 5.6.5.Final (#2516) + * [view commit](https://github.com/yahoo/elide/commit/75db2ed70dc6f00368a626d3e2bde584699c8fc1) Bump log4j-over-slf4j from 1.7.33 to 1 +.7.35 (#2522) + + * [view commit](https://github.com/yahoo/elide/commit/b932cbcba8c9ba993a4fe5b3f859d5691fd976b4) Bump guice from 5.0.1 to 5.1.0 (#2523) + + * [view commit](https://github.com/yahoo/elide/commit/fd2ab7709a90f183ee09cbbec6628af49c003ef5) Bump jedis from 4.0.1 to 4.1.0 (#2525) + + * [view commit](https://github.com/yahoo/elide/commit/d53275d1fb55de45a62269f39e97f0cdd76b573e) Bump slf4j-api from 1.7.33 to 1.7.35 ( +#2519) + * [view commit](https://github.com/yahoo/elide/commit/d0552c5750608391d52e3e3286469901726a7d06) Support filters on query plans (#2526) + +## 6.1.0 +Minor release update for partial support for Elide configuration being 'refreshable' in Spring for a live service. Advanced users overriding some beans may require compilation changes. + +**Features** + * [view commit](https://github.com/yahoo/elide/commit/c38eb980af7f953202cb53faaed14595d3709ed9) Refresh scope beans (#2409) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/833ea32e4a793bd1c7da1d1ad73417c216b1191a) [maven-release-plugin] prepare for nex +t development iteration + * [view commit](https://github.com/yahoo/elide/commit/7a3552d76f8887ab62a60a968412ca442bf745a1) make getColumnProjection() method use +different name for column Id for different source when alias are involved (#2500) + * [view commit](https://github.com/yahoo/elide/commit/4c9bff306e4d0d0f9644c705a241edc7104cc1fe) Bump h2 from 2.0.202 to 2.0.206 (#2476 +) + * [view commit](https://github.com/yahoo/elide/commit/c68b44abdf0b7465695f3fbc347cf3c044f4f1d5) Bump micrometer-core from 1.8.1 to 1.8 +.2 (#2505) + * [view commit](https://github.com/yahoo/elide/commit/cc8d202e113eaa319c2922e86d86d4a8eab31b41) Bump spring-core from 5.3.14 to 5.3.15 + (#2504) + * [view commit](https://github.com/yahoo/elide/commit/df1dfca185d72ce383b84d5ee803534d7ca16dd3) Bump spring-websocket from 5.3.14 to 5 +.3.15 (#2503) + * [view commit](https://github.com/yahoo/elide/commit/2059c44511bdc4294dd5667baa7c0eed19bc0075) Bump slf4j-api from 1.7.32 to 1.7.33 ( +#2502) + * [view commit](https://github.com/yahoo/elide/commit/5718774759f42be1b6b4f34c962d2b64457c3193) Bump log4j-over-slf4j from 1.7.32 to 1 +.7.33 (#2501) + +## 6.0.7 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/7a3552d76f8887ab62a60a968412ca442bf745a1) make getColumnProjection() method use different name for column Id for different source when alias are involved (#2500) + * [view commit](https://github.com/yahoo/elide/commit/4c9bff306e4d0d0f9644c705a241edc7104cc1fe) Bump h2 from 2.0.202 to 2.0.206 (#2476) + +## 6.0.6 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/4caaae234214edf6a61bea57531de79520604d54) File Extension Support for Export Attachments (#2475) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/93baacfea6628cc724b7fb880326a702651b939b) Make CacheKey Unique for same model name across namespaces (#2477) + * [view commit](https://github.com/yahoo/elide/commit/c38857cd07318d5f09b9b087056d627ab01bf156) Cleanup code warnings (#2473) + * [view commit](https://github.com/yahoo/elide/commit/ca46fc6726ed161767d46e1060566ca234cdec5e) Bump build-helper-maven-plugin from 3.2.0 to 3.3.0 (#2481) + * [view commit](https://github.com/yahoo/elide/commit/0f0e94b66532890d4f2ed9f7f34bf7499192d73d) Bump HikariCP from 5.0.0 to 5.0.1 (#2484) + * [view commit](https://github.com/yahoo/elide/commit/c8e3aad63bbdef64c32baf9c538f66e298e1e19a) Bump checkstyle from 9.2 to 9.2.1 (#2485) + * [view commit](https://github.com/yahoo/elide/commit/d13c7b78872df5d4ed7c083dd3883502d4133d8c) Bump maven-site-plugin from 3.9.1 to 3.10.0 (#2480) + * [view commit](https://github.com/yahoo/elide/commit/a5f46a0bb816ab57c43c9b9535d9fd2b8aeb088f) Bump maven-scm-api from 1.12.0 to 1.12.2 (#2479) + * [view commit](https://github.com/yahoo/elide/commit/adb52de7d01eac1e53174d6b7fdc872b7bc81b92) Bump wagon-ssh-external from 3.4.3 to 3.5.1 (#2478) + * [view commit](https://github.com/yahoo/elide/commit/fd9ebf4ffa3a625df38840df723ac1c43605eea9) Bump artemis-jms-client-all from 2.19.0 to 2.20.0 (#2482) + * [view commit](https://github.com/yahoo/elide/commit/e090ab5ed5344760d6239c680515f203548c1596) use alias to get column projection in query plan translator and while nesting projection (#2493) + * [view commit](https://github.com/yahoo/elide/commit/7bf5de6d098acb36b60d40619d35a536cfe1a4ee) Aggregation Store: Fix filter by alias with parameterized metric. (#2494) + * [view commit](https://github.com/yahoo/elide/commit/7f7d029924799a40b07e60f6aaead95cade0136d) Bump maven-jar-plugin from 3.2.0 to 3.2.1 (#2492) + * [view commit](https://github.com/yahoo/elide/commit/8ec4c15d5c04670d364925f390ac1eff6f5bbf3f) Bump log4j-to-slf4j from 2.17.0 to 2.17.1 (#2487) + * [view commit](https://github.com/yahoo/elide/commit/5dcf985d17c967c8882eb23e72a1d1764f53e7dd) Bump swagger-core from 1.6.3 to 1.6.4 (#2488) + * [view commit](https://github.com/yahoo/elide/commit/faf7c68aaf907d2601a0151e7723580369280883) Bump mockito-junit-jupiter from 4.1.0 to 4.2.0 (#2496) + * [view commit](https://github.com/yahoo/elide/commit/5e337f4f3e28642e08daa971b567926594a5f10e) Bump artemis-server from 2.19.0 to 2.20.0 (#2495) + * [view commit](https://github.com/yahoo/elide/commit/493d048bc23c3fdd5029d6db2ff92f84f4368902) Bump artemis-jms-server from 2.19.0 to 2.20.0 (#2491) + * [view commit](https://github.com/yahoo/elide/commit/a074e01864cc7834766e3571e60e0a4c5a15a008) Bump system-lambda from 1.2.0 to 1.2.1 (#2490) + * [view commit](https://github.com/yahoo/elide/commit/d3bb5583a78fcbea62279cb2fb7c99b7da1ac9c6) Bump maven-scm-provider-gitexe from 1.12.0 to 1.12.2 (#2489) + * [view commit](https://github.com/yahoo/elide/commit/1fc1935a940bdcdb8d6b8d6bb17de95e8469b8d5) Bump maven-compiler-plugin from 3.8.1 to 3.9.0 (#2497) + * [view commit](https://github.com/yahoo/elide/commit/104dd73fdba0237a30939764f7267744227e573a) Bump dependency-check-maven from 6.5.2 to 6.5.3 (#2499) + * [view commit](https://github.com/yahoo/elide/commit/35cb0d02603445ca9997bcef5e8085486040d4b4) Bump maven-jar-plugin from 3.2.1 to 3.2.2 (#2498) + * [view commit](https://github.com/yahoo/elide/commit/5d9ce37e2f15a2703f530e83bf9a52eb0632ae6d) Bump calcite-core from 1.28.0 to 1.29.0 (#2483) + +## 6.0.5 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/af4fdc3e859f9737b992f231a857793c912d3039) bump log4j2 (#2460) + * [view commit](https://github.com/yahoo/elide/commit/16384c094b1974e293dc1f33d6330a6b0ac5821a) Use GraphqlBigDecimal for BigDecimal conversion (#2464) + * [view commit](https://github.com/yahoo/elide/commit/6b2b60098436a3dd7db03697ffd4717122fa3bf6) use query with filter to populate query parameters in pagination (#2466) + * [view commit](https://github.com/yahoo/elide/commit/ad03655829b88f6375b95d757be8abc0dbed4fd5) Bump spring-core from 5.3.13 to 5.3.14 (#2456) + * [view commit](https://github.com/yahoo/elide/commit/9a4849d738692723c45dda410587b3e8c1be897a) Bump h2 from 1.4.200 to 2.0.202 (#2427) + * [view commit](https://github.com/yahoo/elide/commit/865d5ac4c2006a8ac998082edbbd14844a375f8a) Bump log4j-api from 2.17.0 to 2.17.1 (#2468) + * [view commit](https://github.com/yahoo/elide/commit/e0b40a7bb8255075d411bf6131ecd2c72f06dbb3) Bump log4j-api from 2.17.0 to 2.17.1 in /elide-spring (#2467) + * [view commit](https://github.com/yahoo/elide/commit/a62df30e3a47af7c4657e8791e01c5403536d9d9) Bump metrics.version from 4.2.5 to 4.2.7 (#2455) + * [view commit](https://github.com/yahoo/elide/commit/cea09b700eec6f45d361ebe9d32ed19e2949cba0) Bump dependency-check-maven from 6.5.0 to 6.5.2 (#2463) + * [view commit](https://github.com/yahoo/elide/commit/6aac9bd0b2ceb2e06dfe1ee7b203fb186a4800db) Bump spring-websocket from 5.3.13 to 5.3.14 (#2461) + * [view commit](https://github.com/yahoo/elide/commit/307f29ba0ac68533f45b2c66f96a5fd0cc7edb8b) Bump mockito-core from 4.1.0 to 4.2.0 (#2458) + * [view commit](https://github.com/yahoo/elide/commit/7ca2d6d204550d2a0498d7ddd67b406620aae69b) jersey to 2.35 (#2472) + * [view commit](https://github.com/yahoo/elide/commit/d554658425300df2210df1e67110f0b7588633e0) Enable lifecycle, check, and other entity scans by default for Spring. (#2470) + * [view commit](https://github.com/yahoo/elide/commit/b931b4d5b2fb5a1ce4040762fc22f2df9f8d30eb) Disallow ConfigFile path to be changed in ConfigStore (#2471) + +## 6.0.4 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/5e9c1d7ff097ec3143de5ab7b20d5bafcf4ff0b0) Fixing #137 (#2433) + * [view commit](https://github.com/yahoo/elide/commit/de351f391f51d0d8a7586e9384c9f2657743a66f) Fixes #2263. JSON-API json processing errors will now return a 400 i… (#2434) + * [view commit](https://github.com/yahoo/elide/commit/ac520d61915aa77253fd69cee3c5713da85ede77) Bump caffeine from 3.0.4 to 3.0.5 (#2435) + * [view commit](https://github.com/yahoo/elide/commit/1cce9a9c4d9d29ab29638e7297000a604954eabb) Bump jetty-webapp from 9.4.43.v20210629 to 9.4.44.v20210927 (#2436) + * [view commit](https://github.com/yahoo/elide/commit/35ba78e82f7e230b5c8d3818ab67df7606e65f29) Fixes #2438 (#2441) + * [view commit](https://github.com/yahoo/elide/commit/a4f89601fb6acc03e6a3a0e0b29d8412e733eb77) Bump metrics.version from 4.2.4 to 4.2.5 (#2442) + * [view commit](https://github.com/yahoo/elide/commit/bd16677ae47419004707f5a7356aa952315c2746) Bump classgraph from 4.8.137 to 4.8.138 (#2445) + * [view commit](https://github.com/yahoo/elide/commit/469fe23e1bc9c8c6639c8df083472f437a69d1ae) Issue608 (#2446) + * [view commit](https://github.com/yahoo/elide/commit/8e1563b2e4abde911563b69a6b5a37ca6361d3a5) Bump micrometer-core from 1.8.0 to 1.8.1 (#2444) + * [view commit](https://github.com/yahoo/elide/commit/cc7b13da0aa6f276c034325be51793f6d6b866c0) Bump httpcore from 4.4.14 to 4.4.15 (#2443) + * [view commit](https://github.com/yahoo/elide/commit/9e2ff4727fbb11947d55536f59409b2e80766290) Resolves #2447 (#2448) + * [view commit](https://github.com/yahoo/elide/commit/2ad81220c9eecb5c009653d72b3204706200d03a) AsyncQueryOperation: fix index out of bounds error for empty list (#2449) + * [view commit](https://github.com/yahoo/elide/commit/0f2187a533022c3847f2bc744d4e1bd6f03e2595) Spaces in physical column name (#2450) + +## 6.0.3 + +**Features** + * [view commit](https://github.com/yahoo/elide/commit/54cdcb9fe67225cd463e1bf9357f0e1d2a42c152) Experimental HJSON Configuration Models and DataStore (#2418) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/38b030daa701f205215b56199705f3d689726527) Fix issue with Enum types in Aggregation Store filters (#2422) + * [view commit](https://github.com/yahoo/elide/commit/21bf8717330d499fcb5baadadb6012c64f84062f) Bump mockito-core from 4.0.0 to 4.1.0 (#2421) + * [view commit](https://github.com/yahoo/elide/commit/e2fcd71b026d3d3a2c207dd48941e38de945f290) Bump spring-websocket from 5.3.12 to 5.3.13 (#2412) + * [view commit](https://github.com/yahoo/elide/commit/1444c00ca7fb2560d38358e01426dfd2eb20350b) Bump version.logback from 1.2.6 to 1.2.7 (#2415) + * [view commit](https://github.com/yahoo/elide/commit/88648be470fc881b1a7019becb141c7ffb78b143) Bump spring-core from 5.3.12 to 5.3.13 (#2416) + * [view commit](https://github.com/yahoo/elide/commit/aae18e32063a48d8b5fb7941becf3ddc6d814430) Bump version.junit from 5.8.1 to 5.8.2 (#2424) + * [view commit](https://github.com/yahoo/elide/commit/a4a796bc72a7b60f145828bee9a3cabc6b9926c1) Bump classgraph from 4.8.132 to 4.8.137 (#2425) + * [view commit](https://github.com/yahoo/elide/commit/a3da66f290a9e68f3e934eec890d479996f772b2) Bump checkstyle from 9.1 to 9.2 (#2426) + * [view commit](https://github.com/yahoo/elide/commit/63b4863f97f621b5b3ccd726f7129b61342566d3) Bump dependency-check-maven from 6.4.1 to 6.5.0 (#2413) + * [view commit](https://github.com/yahoo/elide/commit/d8050f843f8bb392eac26c454de63ab92264184a) Bump junit-platform-commons from 1.8.1 to 1.8.2 (#2429) + * [view commit](https://github.com/yahoo/elide/commit/d2a0749a4903060d8bc064701758d3cfa693da9e) Bump junit-platform-launcher from 1.8.1 to 1.8.2 (#2430) + * [view commit](https://github.com/yahoo/elide/commit/6a14913ac606860b713f332c91701bd66c553bb0) Bump micrometer-core from 1.7.5 to 1.8.0 (#2414) + * [view commit](https://github.com/yahoo/elide/commit/9f4fd01e2c66e9e5a48cc5d79061ad8afb6c9930) Bump spring.boot.version from 2.5.6 to 2.6.1 (#2423) + * [view commit](https://github.com/yahoo/elide/commit/decae2e5d7fb0ad44304af2b4a05c83ba26a809e) Bump mockito-junit-jupiter from 4.0.0 to 4.1.0 (#2428) + * [view commit](https://github.com/yahoo/elide/commit/c8b79396be2432782911c385ec73d0c623a03c72) Security Fix: #147 (#2431) + * [view commit](https://github.com/yahoo/elide/commit/6919c75bf8dd364267ff6e738166cc5d401d0412) Removing extra System.out from test. + +## 6.0.2 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/cc3b1f7edb491d5abfafaa2b2b1e0f3b7caa36bc) Aggregation Store: Arguments should require default values. (#2403) + * [view commit](https://github.com/yahoo/elide/commit/113e7f2e821fb83eeb2915e61c42b81ee659db67) Added IT test for Hibernate Type annotation. (#2404) + * [view commit](https://github.com/yahoo/elide/commit/b7712fc3d8860ab4c564d9cdea65334cfdc7f444) Bump classgraph from 4.8.129 to 4.8.130 (#2406) + * [view commit](https://github.com/yahoo/elide/commit/ab97e119b0c88bf37dac9be38c03cb923173da22) Bump version.antlr4 from 4.9.2 to 4.9.3 (#2405) + * [view commit](https://github.com/yahoo/elide/commit/c8266c116bb3dbd322cfefe86ea23c6320c6b9a8) Fixes async API resultset premature closure (#2410) + * [view commit](https://github.com/yahoo/elide/commit/5f2dc6c29ee678eaf1c89a18e5a4a9abc41bfe37) Bump classgraph from 4.8.130 to 4.8.132 (#2417) + +## 6.0.1 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/85a20be1bd489054abcc5f4c7ed3314d3b0dd03a) Fixing bug where Hibernate is not detecting updates to complex attrib… (#2402) + * [view commit](https://github.com/yahoo/elide/commit/2e5b5efafc9262dbf815fd492eee5e89cf0151fa) Bump mockito-junit-jupiter from 3.12.4 to 4.0.0 (#2398) + +## 6.0.0 +**Overview** + +Official Elide 6 Release. Elide 6 is compiled with Java 11 (as opposed to 8). GraphQL subscription support has been added. Other changes since Elide 5 are summarized here [here](https://elide.io/pages/guide/v6/17-migration.html). + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/3d6107a429a60bb273675eb55aec35c4387f5636) Lifecycle events should trigger once for relationship updates (#2389) + * [view commit](https://github.com/yahoo/elide/commit/7fcbc3ec80fb40bf267cd3a33074dce8e5d8e10b) Added a few additional tests around updating relationships (#2391) + * [view commit](https://github.com/yahoo/elide/commit/e2c15ca498e105b501d63c7d295deeb80c61ab73) Bump gson from 2.8.8 to 2.8.9 (#2394) + * [view commit](https://github.com/yahoo/elide/commit/3939bc4f1fdae70dd3cb8a92f962be544b81f39c) Bump classgraph from 4.8.128 to 4.8.129 (#2396) + * [view commit](https://github.com/yahoo/elide/commit/27d2e82bbcd79d197265b6e7dd976344f02e43ed) Bump checkstyle from 9.0.1 to 9.1 (#2395) + * [view commit](https://github.com/yahoo/elide/commit/5a63aad4d642d63fb8a398c31700b19eb4e3669f) Bump version.junit from 5.7.2 to 5.8.1 (#2326) + * [view commit](https://github.com/yahoo/elide/commit/868f08abb047012f0bc08e1693a7b7cef8a273f0) Bump mockito-junit-jupiter from 3.12.1 to 3.12.4 (#2282) + * [view commit](https://github.com/yahoo/elide/commit/4ce344f9bc00daddae70d77a6a5a6d1642de832b) Issue2376 (#2393) + +## 6.0.0-pr7 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/f652404f0a26657addc821e911a6c61f8159a500) Only check preflush hooks for GraphQL mutations (#2371) + * [view commit](https://github.com/yahoo/elide/commit/1bec9a04edb9917b34a9d168a3bec98f04fa8cad) Changing to Create Preflush (#2379) + * [view commit](https://github.com/yahoo/elide/commit/6a54e45f4e0ebd214003a169b7eaa7244051d366) Sort in memory for computed properties (#2380) + * [view commit](https://github.com/yahoo/elide/commit/5026c6bed03affed67f395a6bb9e4b6206dc62e5) Removing READ life cycle hooks. (#2381) + * [view commit](https://github.com/yahoo/elide/commit/844329422ed1edbcc563222b7e58c2ff861dd503) Adding test for #2376 (#2378) + * [view commit](https://github.com/yahoo/elide/commit/fa377c038f5f1bfd11bbd26d6a16a1691afd91e5) Bump spring-core from 5.3.9 to 5.3.12 (#2375) + * [view commit](https://github.com/yahoo/elide/commit/bcff59867ec1d1d36cd696fca34ad1df66088f59) Bump mockito-core from 3.12.4 to 4.0.0 (#2369) + * [view commit](https://github.com/yahoo/elide/commit/4598a96a3c67f3ba1ace62e9daa35aaf7dd0cb32) Bump maven-javadoc-plugin from 3.3.0 to 3.3.1 (#2298) + * [view commit](https://github.com/yahoo/elide/commit/b55cc49652543c18600a616174032d855d3a34a3) Bump hibernate5.version from 5.5.5.Final to 5.6.1.Final (#2384) + * [view commit](https://github.com/yahoo/elide/commit/37ca4cb76545968f32e75bba490452ac1e119e2e) Bump dependency-check-maven from 6.3.2 to 6.4.1 (#2367) + * [view commit](https://github.com/yahoo/elide/commit/63edada62c36bf004aa6920a31036b6b1cea116a) Bump spring-websocket from 5.3.11 to 5.3.12 (#2383) + * [view commit](https://github.com/yahoo/elide/commit/eac97d952e0cb291651c339d84555617edf0f4c0) Bump spring.boot.version from 2.5.5 to 2.5.6 (#2386) + * [view commit](https://github.com/yahoo/elide/commit/f87d43d0f891238dfe7c3b7fe677c3504eac5e3e) Bump jansi from 2.3.4 to 2.4.0 (#2385) + * [view commit](https://github.com/yahoo/elide/commit/1906a6ead98423b52741f7f849f8946b019e1063) set bypasscache true for Async Export (#2387) + * [view commit](https://github.com/yahoo/elide/commit/3815a731a36a49eb452cb787916ab09c07dd1c12) Bump commons-cli from 1.4 to 1.5.0 (#2388) + +## 6.0.0-pr6 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/b88c19ef352c7bff44d4375d4a5f3e6b55054c3e) Aggregation Store: Make EntityHydrator stream results. (#2333) + * [view commit](https://github.com/yahoo/elide/commit/6f26ba6377010a22403698d48d49ef2a4f897fde) Changing Oath to Yahoo in Copyright messages (#2340) + * [view commit](https://github.com/yahoo/elide/commit/5064f8a5a351b1f77b8025f68b20efc1b76d101e) Elide 6 : Refactor DataStoreTransaction Interface. (#2334) + * [view commit](https://github.com/yahoo/elide/commit/9a63da30302650b557d0b85059494ff8e8220b7b) Fixing toOne relationship bug for GraphQL subscriptions (#2335) + * [view commit](https://github.com/yahoo/elide/commit/0183fe7fe8c5933f82a4ffc3000b7ee638f5f14c) Subscription serialization (#2339) + * [view commit](https://github.com/yahoo/elide/commit/bc9729f22c40de5b864c52186ca2e130daba4637) Issue2337 (#2341) + * [view commit](https://github.com/yahoo/elide/commit/8455b9b8531b2ce031ac5912c96c2c07ce0360bf) Change GraphQL Preflush Hook Invocation (#2332) + * [view commit](https://github.com/yahoo/elide/commit/ca04a4a3c7da52734e31b134c5edbae7efd25a55) Revised JSON-API path grammar to accept colon, space, and ampersand i… (#2342) + * [view commit](https://github.com/yahoo/elide/commit/91d7022c76c673da76df1614617709b7c593d60a) Bump caffeine from 3.0.3 to 3.0.4 (#2327) + * [view commit](https://github.com/yahoo/elide/commit/cd5a7dcac2e525f2c1a2570efe686eb81c135a8d) Bump graphql-java from 17.2 to 17.3 (#2346) + * [view commit](https://github.com/yahoo/elide/commit/47c56d0c2ebbd5410a50fdd39a652b8eeb581138) Bump groovy.version from 3.0.8 to 3.0.9 (#2293) + * [view commit](https://github.com/yahoo/elide/commit/caca78e6bbbba3416cd6892d30c258bfabc0de54) Bump version.logback from 1.2.5 to 1.2.6 (#2324) + * [view commit](https://github.com/yahoo/elide/commit/5dfa639152cb75e689c4b9b52db8bf4953843b1a) Bump artemis-server from 2.18.0 to 2.19.0 (#2349) + * [view commit](https://github.com/yahoo/elide/commit/064d5a86fbbcc5ab927bd3645d5c85ea4139c59e) Bump artemis-jms-server from 2.18.0 to 2.19.0 (#2348) + * [view commit](https://github.com/yahoo/elide/commit/374ffd8ba987171a08d10a0f6655dc4a67918963) Bump spring.boot.version from 2.5.4 to 2.5.5 (#2350) + * [view commit](https://github.com/yahoo/elide/commit/1addd8824d337b690ec5e595a5c94b1fbd87d641) Bump swagger-core from 1.6.2 to 1.6.3 (#2351) + * [view commit](https://github.com/yahoo/elide/commit/6bc5f42fb18b04e926064418268e6c9335150ef1) Bump ant from 1.10.11 to 1.10.12 (#2352) + * [view commit](https://github.com/yahoo/elide/commit/e19227b84e5a372c4831dee7c52e6122c6ceec56) Bump artemis-jms-client-all from 2.18.0 to 2.19.0 (#2354) + * [view commit](https://github.com/yahoo/elide/commit/0e4ee93ab2f55fb91abc34cd55271f60ea613da5) Bump calcite-core from 1.27.0 to 1.28.0 (#2355) + * [view commit](https://github.com/yahoo/elide/commit/b5514565b12f35a6fcaa1216d2cba1b5afdad7a8) Bump metrics.version from 4.2.3 to 4.2.4 (#2356) + * [view commit](https://github.com/yahoo/elide/commit/cbf6efaa0795cf3484ee2dc9789c519a916dd99b) Bump maven-scm-provider-gitexe from 1.11.3 to 1.12.0 (#2353) + * [view commit](https://github.com/yahoo/elide/commit/0d6b0753567d86fca4f06d9bedfacf51816922ae) Bump checkstyle from 9.0 to 9.0.1 (#2358) + * [view commit](https://github.com/yahoo/elide/commit/196db51c342cc129ab37df9b7727217c9e3b8814) Bump handlebars-helpers from 4.2.0 to 4.3.0 (#2359) + * [view commit](https://github.com/yahoo/elide/commit/c4f422a2946de88e961be64b5f67821c5c41b137) Remove export pagination limit (#2362) + * [view commit](https://github.com/yahoo/elide/commit/9660cad7a1479d6ce9d7698e57cbf7b40e6ad92c) Bump classgraph from 4.8.116 to 4.8.128 (#2363) + * [view commit](https://github.com/yahoo/elide/commit/20b4961ecc51a2819a54e42e50746ed612a3dc5d) Bump spring-websocket from 5.3.10 to 5.3.11 (#2360) + * [view commit](https://github.com/yahoo/elide/commit/40481bee942e3a2ba2cfb90b6350f00da76ed190) Bump maven-scm-api from 1.11.3 to 1.12.0 (#2361) + * [view commit](https://github.com/yahoo/elide/commit/830b9f2a997eefcd677090875bede2e21e19f1f4) Support 'hidden' flag for analytic models and fields. (#2357) + * [view commit](https://github.com/yahoo/elide/commit/c3ea3c6a165df84449d717979466b85db5e0a575) Bump micrometer-core from 1.7.3 to 1.7.5 (#2366) + * [view commit](https://github.com/yahoo/elide/commit/db01f98dbec1f78d99d44880dc048a47ab74ba96) Bump lombok from 1.18.20 to 1.18.22 (#2364) + * [view commit](https://github.com/yahoo/elide/commit/cca1dcc92cc1ff37a026d5bd08fd5f747d5b59cb) Bump guava from 30.1.1-jre to 31.0.1-jre (#2365) + + +## 6.0.0-pr5 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/28c378c0c447923b67ab1674df50793480ed12df) Relaxing filter template matching to ignore aliases (#2322) + * [view commit](https://github.com/yahoo/elide/commit/a4e58460b4a2e9bd04ba4707ad31e62dd1dafe01) Values validation on filters that had operators like contains should not be enforced (#2323) + * [view commit](https://github.com/yahoo/elide/commit/3006b1e3aa672bb97c75d276ed9e3c469518976f) Bump junit-platform-launcher from 1.7.2 to 1.8.1 (#2313) + * [view commit](https://github.com/yahoo/elide/commit/5acec4ea96d5786e9b5118d606de40104a6cb851) Bump dependency-check-maven from 6.2.2 to 6.3.2 (#2318) + * [view commit](https://github.com/yahoo/elide/commit/69eb1e6d362bbd6f91d4ff102131f5a631506bce) No longer using attribute aliases to generate CSV export headers. In… (#2325) + * [view commit](https://github.com/yahoo/elide/commit/ae5afa1de35760c3603e743e63b8dbaad32b3951) Bump classgraph from 4.8.115 to 4.8.116 (#2296) + * [view commit](https://github.com/yahoo/elide/commit/23d8cde402d17137d40ce92fabb68d10517a3f2c) Fixing bug for complex attributes contain a Map of Object (#2328) + * [view commit](https://github.com/yahoo/elide/commit/64325a03c2d0e965e2543c3c52a9ddc0de570c03) Aggregation Store: Relaxing rules for how filter templates are compared against filter e… (#2329) + * [view commit](https://github.com/yahoo/elide/commit/9ad0d58b66ead60ede9a13e164fbb5a3c736d889) support case statement in Calcite Aggregation Extractor (#2330) + +## 6.0.0-pr4 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/0eb1624a401c9099c0e9f1f3fb539144cb3b97a1) Added new flag to enable subscription publishing in a separate service. Disallow queries in subscription endpoint. (#2320) + +## 6.0.0-pr3 +Prerelease candidate for Elide 6. + +### New Features in Elide 6.X + +Elide 6 introduces several new features: + - Elide 6 is built using Java 11 (as opposed to Java 8). + - GraphQL subscription support (experimental) is added along with a JMS data store that can read Elide models from JMS topics. + +### API Changes + +Prior to Elide 6, updates to complex, embedded attributes in Elide models required every field to be set in the attribute or they would be overwritten with nulls. Elide 6 is now aware of individual fields in complex, embedded attributes and only changes what has been sent by the client. See [#2277](https://github.com/yahoo/elide/issues/2277) for more details. + +### Interface Changes + + - EntityDictionary is now entirely constructed with a Builder. All prior constructors have been removed. + - Security checks are now instantiated at boot and reused across requests. This change requires security checks to be thread safe. + +### Module & Package Changes + +The following packages havea been removed: + + - Legacy datastore packages elide-hibernate-3 and elide-hibernate-5 have been retired and can be replaced with the JPA data store. + - The Elide bridgeable datastore has been removed. + - The package elide-datastore-hibernate has been renamed to elide-datastore-jpql. Internal package structure for the module was also changed. + +## 5.1.2 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/f54c0343fae51b8827da7bd719c460f39c3d56b1) Security Fix: #147 for elide-5.x (#2432) + +## 5.1.1 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/076a3171fa9c2240c16a2d3f2ced259d46202f7a) Fixes async API resultset premature closure. (#2411) + +## 5.1.0 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/2bd8e22fba35aeabc8f00b9836364b06a5abeea0) UPdating screwdriver build + * [view commit](https://github.com/yahoo/elide/commit/40d1413a725d61689892044e0ba6f21716cd5a84) Disabling OWASP checks for legacy build (Elide 5) + * [view commit](https://github.com/yahoo/elide/commit/cad5a714fd4eb3ce7dc6e95d8b1dba5a7ebbd379) (Similar to https://github.com/yahoo/elide/pull/2342) Revised JSON-API path grammar to accept colon, space, and ampersand in ID fields (#2343) + +## 5.0.12 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/0b7eb0cb8b9fbb37fa412863a6d6fd1ac5734948) Add ability to retrieve data store properties in life cycle hooks. (#2278) + * [view commit](https://github.com/yahoo/elide/commit/55e61646f17d5bdb94d4519ab210ac56840778db) Aggregation Store: Templated filter table arguments (#2290) + * [view commit](https://github.com/yahoo/elide/commit/cc0dffc51428b5b9ee255896169de91c69af0314) AggregationStore: Templated filter column arguments (#2297) + * [view commit](https://github.com/yahoo/elide/commit/65eaaa12fc2b805135285287d4912d2329bc676d) Add ability to map unknown exceptions to custom exceptions (#2205) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/fe7009353573baf0206f7bb58617db97e067f900) Refactor class scanning for quarkus (#2284) + * [view commit](https://github.com/yahoo/elide/commit/2a477c4fcf16d001e1ab87cc35e54662d77da870) Fixed bug where multiplex manager was not copying complex attributes from subordinate dictionaries (#2285) + * [view commit](https://github.com/yahoo/elide/commit/84ea9f15d9783a981027061c47a2873a59c410f4) Updating screwdriver build for JDK11 and Elide 6 (#2286) + * [view commit](https://github.com/yahoo/elide/commit/6f0435be5f2a4467ecb8be17b5393bbb54b3e690) Bump checkstyle from 8.45.1 to 9.0 (#2289) + * [view commit](https://github.com/yahoo/elide/commit/65fa257e9af0a39fa1a1d23b66085f6edebfca59) Aggregation Store: Fix hjson metric projection maker config (#2295) + * [view commit](https://github.com/yahoo/elide/commit/dc9d533f4a0709c8442a20716b742a1aea95d76d) Bump graphql-java from 17.1 to 17.2 (#2281) + * [view commit](https://github.com/yahoo/elide/commit/ba8776d20bb345fae477f9a83987654087e28c0a) Bump version.jackson from 2.12.4 to 2.12.5 (#2280) + +## 5.0.11 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/798c66204779a03bb4e34554ae243f9c4ea25bc7) Added support for filtering & sorting on complex model attributes. (#2273) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/a01da5016669203413a2adb3b866290e95997a7c) Disable PR build from forked repo (#2269) + * [view commit](https://github.com/yahoo/elide/commit/ccbb190dd03cb66325abc33820cc14ea252a5cb0) Return error to client when performing model edits in a GraphQL query… (#2274) + * [view commit](https://github.com/yahoo/elide/commit/dadcdcde922ddd1f6fe9ee7a24f7508d2901c6d1) Bump mockito-junit-jupiter from 3.11.2 to 3.12.1 (#2268) + * [view commit](https://github.com/yahoo/elide/commit/22f28238f387513f06537f252a8106fc04975567) Bump mockito-core from 3.11.2 to 3.12.4 (#2276) + * [view commit](https://github.com/yahoo/elide/commit/b14b36dcf051c734e3143e0db67d7266d0b2066e) Bump spring.boot.version from 2.5.3 to 2.5.4 (#2265) + * [view commit](https://github.com/yahoo/elide/commit/344d4e4e1783101a2bff47d79143a19fb3c3c9bb) Bump gson from 2.8.7 to 2.8.8 (#2266) + +## 5.0.10 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/debf5ca4d4a53dde274642cfbc4bc04060cbd949) httpclient 4.5.13 (#2074) + * [view commit](https://github.com/yahoo/elide/commit/10697e7f97790b41bfa63b0d8d007bcb95547cac) Bump classgraph from 4.8.113 to 4.8.114 (#2255) + * [view commit](https://github.com/yahoo/elide/commit/b24fbce881aebcfbc58aac83592a335f2bc894f9) Bump micrometer-core from 1.7.2 to 1.7.3 (#2256) + * [view commit](https://github.com/yahoo/elide/commit/0592714a73377945c205f4faf7bc4f23122e519b) loadObject should return unique result (#784) + * [view commit](https://github.com/yahoo/elide/commit/a6d77b362ae629732912e6d6085d1f1ecd5e0b05) Bump maven-scm-provider-gitexe from 1.11.2 to 1.11.3 (#2258) + * [view commit](https://github.com/yahoo/elide/commit/c25d63ee4504107bdadf3523c7321c632d2ca890) Bump maven-scm-api from 1.11.2 to 1.11.3 (#2254) + * [view commit](https://github.com/yahoo/elide/commit/97c7b01538a4c8715f75b9586e8dff3f479053b3) Bump classgraph from 4.8.114 to 4.8.115 (#2261) + * [view commit](https://github.com/yahoo/elide/commit/6a92b433ac59bd39c555a8b3e5dbd3ec9b1df965) Bump artemis-server from 2.17.0 to 2.18.0 (#2259) + * [view commit](https://github.com/yahoo/elide/commit/1926ea9adb14b981aa4e9f7247906a4f5567e16b) Bump artemis-jms-client-all from 2.17.0 to 2.18.0 (#2257) + * [view commit](https://github.com/yahoo/elide/commit/6df21a4cf84be5bbdefb2a725d1c0dba853d8fb8) Makes Elide JSONPath version proof (#2262) + +## 5.0.9 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/8e15a2dccdb6d7d9107a5cc747355d5754371a83) Reduce boot startup time. Reducing ClassGraph scans to a single scan (#2253) + +## 5.0.8 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/5e8b945b8bd641c8120c8601126564e02eab8f5c) GraphQL Subscriptions PR1: Subscription model builder (#2243) + * [view commit](https://github.com/yahoo/elide/commit/3d2873533534452f09e5c19ddd52395cd8ff6777) GraphQL Subscriptions PR2: Jms data store (#2244) + * [view commit](https://github.com/yahoo/elide/commit/76fff8684b7ea31d3ee1483aa4078d7949e5442f) Added enhanced logging around JPQL queries (#2249) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/1520be9927ee83d836c5fbe869ca365e002a1464) Bump checkstyle from 8.44 to 8.45.1 (#2241) + * [view commit](https://github.com/yahoo/elide/commit/bef2526fe2512f8c18653825fddfc89e18aec8cc) Bump classgraph from 4.8.110 to 4.8.113 (#2247) + * [view commit](https://github.com/yahoo/elide/commit/648499b6a5996dd09716bbcdf9b12b11f095b48c) Add ability for a SQLDialect to override the system default SQL filte… (#2250) + * [view commit](https://github.com/yahoo/elide/commit/49aae8b0b47b17c79db6c0f136d4be86991e0023) Bump graphql-java from 16.2 to 17.1 (#2246) + * [view commit](https://github.com/yahoo/elide/commit/4d5cf76acf92afb3c14608cbfbbc3d996e230d57) Removing references to Verizon & Verizon Media from Elide project (#2229) + +## 5.0.7 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/981125df2571d5a7ec2c0bde76a7c897b4a803c9) Bump ant from 1.10.10 to 1.10.11 in /elide-graphql (#2233) + * [view commit](https://github.com/yahoo/elide/commit/9e4f2929f727a5042607d066a8b9d5a8b1f316b5) Bump maven-enforcer-plugin from 1.4.1 to 3.0.0 (#2232) + * [view commit](https://github.com/yahoo/elide/commit/a51cc2f178d36002ade4905c0179cc3510f5db0d) Bump classgraph from 4.8.108 to 4.8.110 (#2230) + * [view commit](https://github.com/yahoo/elide/commit/c55eca8c6cc828610faf39d0ea62d2a0a897696b) Add simple configuration to enable verbose errors. (#2236) + * [view commit](https://github.com/yahoo/elide/commit/101f842daa06095652f898bda0f96fead4bb373e) GraphQL schema now namespaces internal types to avoid conflicts (#2237) + * [view commit](https://github.com/yahoo/elide/commit/a874733a5c2ac3777e5946f6eddfade651d80c7a) Fix bug where dialect time conversion is skipped (#2238) + * [view commit](https://github.com/yahoo/elide/commit/f0f455bee3d51e2333ad20005068f48ab563a7a9) Issue2239 (#2245) + +## 5.0.6 +Fixes bug in 5.0.5 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/846755e07bc2a7092a8393c698205323cd333a2c) Fixed mismatching graphQL versions in pom (#2234) + +## 5.0.5 +A major change for this release is a large upgrade of the graphql-java package to the latest version. + +**Features** + * [view commit](https://github.com/yahoo/elide/commit/c8ce46d5e0d8323ac06187be86c39cc844a460f4) Added PREFLUSH phase for lifecycle hooks (#2224) + * [view commit](https://github.com/yahoo/elide/commit/73102d5d6a2fdf35d70eb97d53a5123fe20a425c) Use include to set model description (#2222) + * [view commit](https://github.com/yahoo/elide/commit/bca07bcf3aa10949ce5e86831ca27045f49aa2cf) Add support for Swagger ApiModel annotation (#2206) + * [view commit](https://github.com/yahoo/elide/commit/90e332d27becbdada09b59b48c5ab614a0f33160) Added spanish translation for README.md (#2125) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/320df5eedbc3f6d01512fc6cf4be0be90c2cff4b) Exclude json-path from calcite (#2186) + * [view commit](https://github.com/yahoo/elide/commit/6e550d1a14f63d1f5e047b1404359f840d87504c) Bump spring.boot.version from 2.5.1 to 2.5.2 (#2183) + * [view commit](https://github.com/yahoo/elide/commit/76583615a1f17e971396f990414ef8de6bda055a) Bump mockito-core from 3.11.1 to 3.11.2 (#2182) + * [view commit](https://github.com/yahoo/elide/commit/0b0ddbfd30ffeac94328d9e06dbd7c2272a7a31a) Bump version.jackson from 2.12.3 to 2.12.4 (#2192) + * [view commit](https://github.com/yahoo/elide/commit/99721518c57e8208bf788dfb7c4e1a85b2c9977d) Bump mockito-junit-jupiter from 3.11.1 to 3.11.2 (#2181) + * [view commit](https://github.com/yahoo/elide/commit/870fdfcf3dbcfc15f150c644954b2a6c3ff1e0b9) Bump metrics.version from 4.2.1 to 4.2.2 (#2194) + * [view commit](https://github.com/yahoo/elide/commit/a387ba0556486946688480c3703e97f2c978ab3f) Bump commons-io from 2.10.0 to 2.11.0 (#2197) + * [view commit](https://github.com/yahoo/elide/commit/c89b2b00fb24ee10506d4fa03a2fcc7cb102856f) Bump jetty-webapp from 9.4.41.v20210516 to 9.4.43.v20210629 (#2202) + * [view commit](https://github.com/yahoo/elide/commit/fe862c969c608af94e5f6fb9a54c0066fec424dd) Bump spring-core from 5.3.8 to 5.3.9 (#2200) + * [view commit](https://github.com/yahoo/elide/commit/5c48344d8926c18464277f8ef11069ba4c01969e) Bump commons-compress from 1.20 to 1.21 (#2201) + * [view commit](https://github.com/yahoo/elide/commit/4351fca3d4cf49af223f42e723d8f10e20ce2c7c) Bump dependency-check-maven from 6.1.6 to 6.2.2 (#2148) + * [view commit](https://github.com/yahoo/elide/commit/b0f68eb78cc029ea82db9c81285309b8a6b3c48b) Bump checkstyle from 8.41 to 8.44 (#2184) + * [view commit](https://github.com/yahoo/elide/commit/adf25ee5712f2127885757d2055424401cffd42a) Bump jedis from 3.6.1 to 3.6.3 (#2210) + * [view commit](https://github.com/yahoo/elide/commit/c1ce234484f194ec0436ebf386ecf988e9ae265f) Bump micrometer-core from 1.7.0 to 1.7.2 (#2209) + * [view commit](https://github.com/yahoo/elide/commit/0c4e0e9187f81997ab6a1ceb9cfc50fc3e3848c8) Bump jansi from 2.3.3 to 2.3.4 (#2208) + * [view commit](https://github.com/yahoo/elide/commit/7edc5d2b329f99f6d14a44f9bd8969d7c9444576) Bump version.logback from 1.2.3 to 1.2.5 (#2211) + * [view commit](https://github.com/yahoo/elide/commit/5cdac5926b504bdf5ae566b1c487af768f00cfcf) Bump log4j-over-slf4j from 1.7.31 to 1.7.32 (#2212) + * [view commit](https://github.com/yahoo/elide/commit/7d863fcbac6f6304223a50caddbb171a85ebc7fa) Resolves #2220 (#2221) + * [view commit](https://github.com/yahoo/elide/commit/004c2e2aab78e131f5acc2f60fbaa3e689220112) Resolves #2215 (#2219) + * [view commit](https://github.com/yahoo/elide/commit/272b3830afb064dbffdc44ac8586a9c4df3e7d94) Switch to discord (#2223) + * [view commit](https://github.com/yahoo/elide/commit/d5e07dde01bbd12201ccba95f5c4289eab4957f3) Fix invite link for Discord (#2226) + * [view commit](https://github.com/yahoo/elide/commit/e64008921d7ad27fc80c930d8fc1f8eb4847c132) Bump metrics.version from 4.2.2 to 4.2.3 (#2207) + * [view commit](https://github.com/yahoo/elide/commit/9f8a03b8868befff6554ea28c92579a353a366ee) Bump spring.boot.version from 2.5.2 to 2.5.3 (#2214) + * [view commit](https://github.com/yahoo/elide/commit/6cb71dd3211a333b42ebcfb8e77d5185d21f8c13) Bump hibernate5.version from 5.5.2.Final to 5.5.5.Final (#2227) + * [view commit](https://github.com/yahoo/elide/commit/bcbf0cbdb1757f795207a78e00f097c31e6174c0) Bump slf4j-api from 1.7.31 to 1.7.32 (#2218) + * [view commit](https://github.com/yahoo/elide/commit/cc7da810dbef23aee6412bf2aaba29f9c6c0b897) Upgrade graphql-java from 6 to 16.2 (#2228) + +## 5.0.4 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/987311f96ee5883279f63dc4fee8c888bdb52340) Fix defaultMaxPageSize setting (#2177) + * [view commit](https://github.com/yahoo/elide/commit/f9c2304cdbd7a1fb0f62cb024a19b2f2335832f2) Make AS before table alias optional, i.e. dialect based (#2179) + * [view commit](https://github.com/yahoo/elide/commit/f6eff8a0612919e2476a680a9d70964e5a0c14c6) Bump slf4j-api from 1.7.30 to 1.7.31 (#2172) + * [view commit](https://github.com/yahoo/elide/commit/4756f7131e09efb6a37bd1f97f39eda148324e1a) Bump metrics.version from 4.2.0 to 4.2.1 (#2173) + * [view commit](https://github.com/yahoo/elide/commit/fa65687f2aef93cb84a47698450fd7875ae3f9e5) Bump jetty-server from 9.4.40.v20210413 to 9.4.41.v20210516 (#2176) + * [view commit](https://github.com/yahoo/elide/commit/16e251c4829459d629adfa5e4f7c8fedde752391) Bump log4j-over-slf4j from 1.7.30 to 1.7.31 (#2174) + +## 5.0.3 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/6eef744fb655ee054ad2559284afa7ec082d3c2b) use column name to extract cache key in agg datastore (#2144) + * [view commit](https://github.com/yahoo/elide/commit/655d8975589d9d40462e9abd1a7bfb468bac2894) use CheckPermission for CreateObject to include user checks and operation checks (#2143) + * [view commit](https://github.com/yahoo/elide/commit/ce2433410a458438e8d3dfbe22416f916513267e) Bump spring.boot.version from 2.4.6 to 2.5.1 (#2149) + * [view commit](https://github.com/yahoo/elide/commit/44129901a2a05c113e9bd98de8a591498e24a989) Bump mockito-junit-jupiter from 3.11.0 to 3.11.1 (#2151) + * [view commit](https://github.com/yahoo/elide/commit/0310c513ef8c13580b23bc88e3e498463ffa4820) Bump commons-io from 2.9.0 to 2.10.0 (#2150) + * [view commit](https://github.com/yahoo/elide/commit/96ae87384ea28304efe6160396326f74a0cca062) Bump hibernate5.version from 5.4.30.Final to 5.5.2.Final (#2152) + * [view commit](https://github.com/yahoo/elide/commit/e55a4cc2380f09761cb039a6b35ec29066307322) Bump jedis from 3.6.0 to 3.6.1 (#2147) + * [view commit](https://github.com/yahoo/elide/commit/52329bd57064352e042f01fecd3c15721dd9ac04) Bump jansi from 2.3.2 to 2.3.3 (#2155) + * [view commit](https://github.com/yahoo/elide/commit/018b1c473631148f47d54e65ac0446714f05ab05) Bump json-path from 2.4.0 to 2.6.0 (#2130) + * [view commit](https://github.com/yahoo/elide/commit/44da3b6f2e8dc40c99e9ff3fb38ecc7cacca322c) Bump spring-core from 5.3.7 to 5.3.8 (#2154) + * [view commit](https://github.com/yahoo/elide/commit/f0c8c100194841f47ad588a42521abb57aa20953) Bump HikariCP from 4.0.2 to 4.0.3 (#2157) + * [view commit](https://github.com/yahoo/elide/commit/d2751e0c8f9ab236ad3849ba5bbf4830c1a7f98c) Bump mockito-core from 3.11.0 to 3.11.1 (#2159) + * [view commit](https://github.com/yahoo/elide/commit/19cfd236db24561a66faa9c43fd6c30e0c5893fe) Fix suggestionColumns to add correct columns (#2164) + * [view commit](https://github.com/yahoo/elide/commit/e1c3e187fe53ea5c045620fe446abdee8273bfab) Bump classgraph from 4.8.105 to 4.8.108 (#2156) + * [view commit](https://github.com/yahoo/elide/commit/5a21949eb0cf012f246f8a481d17736435a0802d) Allow multivalue params and fix header sanitization (#2169) + +## 5.0.2 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/450874f676513d58485014897f4864aa50a98bfd) Support for EmbeddedId (#2132) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/6622a0f857df15031e5e7abda2dca0750c35fce3) Updating suppressions.xml to ignore hibernate 3 CVE errors for the legacy hibernate 3 data store. Use at your own risk or upgrade to hibernate 5 (#2127) + * [view commit](https://github.com/yahoo/elide/commit/51b61f2841e9be5fa4f7afd8ecaab243cc793826) Bump hibernate-validator from 6.1.0.Final to 6.1.5.Final in /elide-async (#2126) + * [view commit](https://github.com/yahoo/elide/commit/6a80b04abf4ee09e633a3cd1ded2df15f985c63b) Bump mockito-junit-jupiter from 3.9.0 to 3.11.0 (#2129) + * [view commit](https://github.com/yahoo/elide/commit/3c1f14e9588b3ef3a5ecbb865d3d74ec69a2e46d) Bump calcite-core from 1.26.0 to 1.27.0 (#2131) + * [view commit](https://github.com/yahoo/elide/commit/3da93d519295fc05ae845ab78ebe65f24120a47b) Bump junit-platform-launcher from 1.7.1 to 1.7.2 (#2113) + * [view commit](https://github.com/yahoo/elide/commit/2e6beca2d833aace35c6cf76e306ecd2cfb04823) Bump jansi from 2.1.1 to 2.3.2 (#2083) + * [view commit](https://github.com/yahoo/elide/commit/a633890761169e682088d9a1960a974b9f32d251) Bump gson from 2.8.6 to 2.8.7 (#2133) + * [view commit](https://github.com/yahoo/elide/commit/e86211f739c2f288262a9481f34086ba4773d611) Fixing CVE-2021-22118 (#2137) + * [view commit](https://github.com/yahoo/elide/commit/b9183cf0ec70988fa067bbe266fec006be93b063) Bump ant from 1.10.9 to 1.10.10 (#2077) + * [view commit](https://github.com/yahoo/elide/commit/0b9cde0aa3aa5216adffc5da164b46f92f7ba2e6) Bump version.restassured from 4.3.3 to 4.4.0 (#2136) + * [view commit](https://github.com/yahoo/elide/commit/a68f7670c8e0137e1fd86a9c82feafcf4e87331a) Bump mockito-core from 3.10.0 to 3.11.0 (#2138) + * [view commit](https://github.com/yahoo/elide/commit/c66df3772034df94e7651e2fc1cae97dcd442c95) Bump commons-io from 2.8.0 to 2.9.0 (#2139) + * [view commit](https://github.com/yahoo/elide/commit/53b029d1477ab7faeb1add5c0389f463363dd7cf) Bump micrometer-core from 1.6.6 to 1.7.0 (#2134) + * [view commit](https://github.com/yahoo/elide/commit/cd52b90c9f472c01325fd285eeac1d0092e8af95) Bump maven-javadoc-plugin from 3.2.0 to 3.3.0 (#2140) + * [view commit](https://github.com/yahoo/elide/commit/f939e4a5f757fef7402a503630d04a66c1c843e4) Bump maven-gpg-plugin from 1.6 to 3.0.1 (#2135) + * [view commit](https://github.com/yahoo/elide/commit/6cbffcbe143357215eeac1485005dc8ea66310a1) Bump nexus-staging-maven-plugin from 1.6.7 to 1.6.8 (#2078) + * [view commit](https://github.com/yahoo/elide/commit/e46b8461c27d26112e3fe5794f8aaf5b4857ea67) Small edits to readme to reflect Elide 5. (#2141) + +## 5.0.1 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/8b713e15d262f0b0087c9de79dc45fca16891ca0) Update README.md + * [view commit](https://github.com/yahoo/elide/commit/a691913ed2bcb782c6659d3b24c3621ad0fa47da) Fix bug querying a dynamic analytic model by ID in GraphQL (#2120) + +## 5.0.0 + +Elide 5 Official Release. See the [migration guide](https://elide.io/pages/guide/v5/17-migration.html) for a full recap of changes since Elide 4. + +**Features** + +Major changes since last pre-release include: + * Parameterized tables, metrics, & dimensions for Aggregation store models. + * A new security model (permission executor) for Aggregation store models. + * @Include at package level introduces the concept of 'Namespaces'. + * @Include includes metadata (that will soon be added to Swagger and Graphiql documents). + * A query optimizer was added for Aggregation Store. + * Metadata changes for the Aggregation Store to support richer search suggestions. + * [view commit](https://github.com/yahoo/elide/commit/63b444aa04e4c0e9b6fcb16cbb8560a9dba6fa79) Expose query params on security.RequestScope (#2067) + * [view commit](https://github.com/yahoo/elide/commit/9facb56979112d0ce1d09f9a529b7a32950d4dd6) Allow custom serdes to override default ones (#1421). (#2103) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/fdfe852b9498de2748f127e618b5436bf854edd2) Disable Dependency Check (#2017) + * [view commit](https://github.com/yahoo/elide/commit/2cb51c39ababdbfbd270f7478710f05122d5c31b) Table Namespace Model Config (#2013) + * [view commit](https://github.com/yahoo/elide/commit/7a901acc7407dedfe5794e3509683649e746bd58) Test Cases for Namespace Dynamic Configs (#2015) + * [view commit](https://github.com/yahoo/elide/commit/89e96b0bb45da69b08e6c8df6d8d7d23090eec37) Resolution using Table Context (#2004) + * [view commit](https://github.com/yahoo/elide/commit/ff182deee468044339501f0818a089a63189bb2f) Revert "Disable Dependency Check (#2017)" (#2023) + * [view commit](https://github.com/yahoo/elide/commit/0c9fff2684f6bff02222fd36c9b12a686ee655ef) ClassType.of (#2027) + * [view commit](https://github.com/yahoo/elide/commit/0cb3f2505270b755cc50a5a646719da32a533f33) Update README.md (#2024) + * [view commit](https://github.com/yahoo/elide/commit/9fb218f29b6fd0b3c803bbe0216f0ee9366129ea) Bump version.jackson from 2.12.2 to 2.12.3 (#2005) + * [view commit](https://github.com/yahoo/elide/commit/6143d108eb7b1a2250370409d5362c6722c95b23) Cleanup (#2026) + * [view commit](https://github.com/yahoo/elide/commit/50a41527ad49571d5029b922382647184752027d) Fix handling in filter pushdown extractor (#2030) (#2038) + * [view commit](https://github.com/yahoo/elide/commit/53b23330ff54b12041e530a931329986ddd3e454) Aggregation Store: Namespace Metadata changes (#2037) + * [view commit](https://github.com/yahoo/elide/commit/e64bd8e8ab6ff8353b7a0bb1d4db03c8a4eedaac) Permission visitor normalization (#2035) + * [view commit](https://github.com/yahoo/elide/commit/ea57369f2b8af263efd9de99bfe59cae1b88202a) Test Cases + Enable Namespace in Standalone and Spring (#2040) + * [view commit](https://github.com/yahoo/elide/commit/b126fc12c0102fa8441e5900b81ad95d1b0bb3c3) Use TableContext to resolve join expressions. (#2036) + * [view commit](https://github.com/yahoo/elide/commit/b911028feb3655bb897041047caa7602f3f0c761) Add missing default values for ElideAutoConfiguration (#2052) + * [view commit](https://github.com/yahoo/elide/commit/256ca8e18dbae4b8ff6c1adbd8e37b236ef5e575) Refactor Column Projections (#2049) + * [view commit](https://github.com/yahoo/elide/commit/4e611ab9f310af630c79dc9e475af45127e40246) Upgrade to GitHub-native Dependabot (#2041) + * [view commit](https://github.com/yahoo/elide/commit/eda2eb0643884d0c145e132ad76c0e28e3ac699a) Bump metrics.version from 4.1.19 to 4.1.21 (#2062) + * [view commit](https://github.com/yahoo/elide/commit/4e8ea8eebe887ecfca30d45491ff7cfa82c28bcd) Bump groovy.version from 3.0.7 to 3.0.8 (#2059) + * [view commit](https://github.com/yahoo/elide/commit/e7224f95f46446e9787f6e03a8837fd65ae9368c) Bump spring-core from 5.2.9.RELEASE to 5.3.6 (#2058) + * [view commit](https://github.com/yahoo/elide/commit/a4e09c25871bdef9ab5088ea4d2d3bef920f772d) Refactored AnyFieldExpression logic (#2050) + * [view commit](https://github.com/yahoo/elide/commit/6591f9a7c5def55726e081dc0aecf15cbb915c44) Bump version.antlr4 from 4.9.1 to 4.9.2 (#2060) + * [view commit](https://github.com/yahoo/elide/commit/d21f942a219ba7f32757f334d7e61c78e126d6be) Use only queried column args & default args for logical column expansion (#2054) + * [view commit](https://github.com/yahoo/elide/commit/ee220445dbd72d789e09dbea5c233b46b4768f3e) Bump junit-jupiter-params from 5.7.0 to 5.7.1 (#2057) + * [view commit](https://github.com/yahoo/elide/commit/dd33ea956b389e5fb37ea313564b7c80f0891bb9) Bump spring.boot.version from 2.4.3 to 2.4.5 (#2033) + * [view commit](https://github.com/yahoo/elide/commit/26f9d627781a4023f19c81065bd0434159b9da1e) Bump guava from 30.1-jre to 30.1.1-jre (#2056) + * [view commit](https://github.com/yahoo/elide/commit/21b4c5a728a2fd55339282b1e13bfb02cf26c875) get permission executor from datastore for each model that it manages. (#2070) + * [view commit](https://github.com/yahoo/elide/commit/16107fbc78798004044ae626930d82159b4dabca) Bump maven-site-plugin from 3.7.1 to 3.9.1 (#2055) + * [view commit](https://github.com/yahoo/elide/commit/10a8a027e896e0ed928ef2f8e4c3bf5808c023da) Source sparse fields from entity projection (#2051) + * [view commit](https://github.com/yahoo/elide/commit/cc1a380deaf5ea3ca3c9f7e0df716198f717a4fe) Bump commons-lang3 from 3.11 to 3.12.0 (#1913) + * [view commit](https://github.com/yahoo/elide/commit/b7d1e10c5d98bc7e5fd811df45395e0a0216561f) Bump mockito-core from 3.6.28 to 3.9.0 (#2034) + * [view commit](https://github.com/yahoo/elide/commit/5c260f3ea1fc87ad6d3fb7be9b6354d67a294804) [Security] Bump version.jetty from 9.4.39.v20210325 to 9.4.40.v20210413 (#2032) + * [view commit](https://github.com/yahoo/elide/commit/d2843b24f4a1ed35907726ff62f09049ce37d081) Optional flow (#2069) + * [view commit](https://github.com/yahoo/elide/commit/222f1e1b616343984d71243a0f8c23cb2610b4a6) Bump micrometer-core from 1.6.4 to 1.6.6 (#2006) + * [view commit](https://github.com/yahoo/elide/commit/73eb5ded68c41c661516fed08684a53bcb89c9b1) Add ability to handle conversion of Date instances to ISO8601DateSerde (#2047) + * [view commit](https://github.com/yahoo/elide/commit/bdeef8376dfd3c35b1f12d0fc41a51385b16fa5d) Bump caffeine from 2.8.8 to 3.0.2 (#2064) + * [view commit](https://github.com/yahoo/elide/commit/40cc29938d205453201062f930a8724a18572740) Bump dependency-check-maven from 6.1.5 to 6.1.6 (#2081) + * [view commit](https://github.com/yahoo/elide/commit/ea7919943a77cb7ee33668170e7da16e9e338bc4) bump dependency-check (#2076) + * [view commit](https://github.com/yahoo/elide/commit/1bb35bea711c420f00cfd97596dfa9177e15277e) Populating namespaces in metadatastore (#2071) + * [view commit](https://github.com/yahoo/elide/commit/e722d3e2ac0acf976920871dd0a52a14f2cfd545) Refactor tableSource for Column and Argument (#2075) + * [view commit](https://github.com/yahoo/elide/commit/b8a9ae644bff7db48b761d83cfac20821f8029e3) Context for partially resolving expressions (#2068) + * [view commit](https://github.com/yahoo/elide/commit/066142e9acd4bd2582aa4e2eb5683a8bbd31d100) Remove usage of {{}} for time demensions (#2011) + * [view commit](https://github.com/yahoo/elide/commit/0a3776bacb5809845d9a04a1263db9760f117b92) Injection Protection for parameterized metrics/dimensions (#2090) + * [view commit](https://github.com/yahoo/elide/commit/35e214dd718e164e30529eff4feab4d5fcd62d27) Resolve arguments in FromSubquery (#2089) + * [view commit](https://github.com/yahoo/elide/commit/e5e5f921b666bd7a6277832b6afa2d7f7cbe25a8) change permission executor cache to store set of fields (#2091) + * [view commit](https://github.com/yahoo/elide/commit/bf246b21912e4b299e4330089198ba2eee01fbec) Generate dynamic alias for join source (#2065) + * [view commit](https://github.com/yahoo/elide/commit/20cccc1e7386d25e7f112e912822afdfc612e79a) Validate required arguments (#2092) + * [view commit](https://github.com/yahoo/elide/commit/c82b507d31043f0956023f9b83040b71277df02e) Aggregation Store: Verify write requests are denied (#2096) + * [view commit](https://github.com/yahoo/elide/commit/b75011e6d084b36ce3b9ada52e309cf1cf5bdba5) Add AggregationStorePermissionExecutor (#2086) + * [view commit](https://github.com/yahoo/elide/commit/0cb5028cb5c37225e4f30d5a08469499cc9fba22) add validation for security checks in agg model table, namespace and fields (#2088) + * [view commit](https://github.com/yahoo/elide/commit/c3d71ad98a89137ed78e86deb091897836203f75) Propogate client query arguments to join tables (#2095) + * [view commit](https://github.com/yahoo/elide/commit/32856f2244a3334028099eeb7ecd13b69fa41016) Aggregation Store ID Column Metadata (#2099) + * [view commit](https://github.com/yahoo/elide/commit/06fbda9b30433d71d45e62ceab5727246c59b904) use AggregationStorePermissionExecutor for Aggregation Store Model (#2102) + * [view commit](https://github.com/yahoo/elide/commit/4f0ff5f77322d56d33e3de3c961f96bb9ce5d26f) Aggregation Store - Optimizer complex formula tests (#2101) + * [view commit](https://github.com/yahoo/elide/commit/6d341a829a16ea8c1dc0fb2815e320601545467d) Parse table and column arguments in Expression parser (#2105) + * [view commit](https://github.com/yahoo/elide/commit/7bc4ed6f28c7ac31f1fa8071bb30ae0f5f8f7407) Added column metadata for HJSON ID column (#2104) + * [view commit](https://github.com/yahoo/elide/commit/58c194cd875ffaeb01219bc3aa7fded0c8fb3da3) Bump hibernate-search-orm from 5.11.8.Final to 5.11.9.Final (#2085) + * [view commit](https://github.com/yahoo/elide/commit/815b201d91369e2580ad43e81d75c462f4c494c5) Bump jacoco-maven-plugin from 0.8.6 to 0.8.7 (#2080) + * [view commit](https://github.com/yahoo/elide/commit/bc656ea2fdef26b6d522d55d39a034085eeb5a25) Bump classgraph from 4.8.104 to 4.8.105 (#2079) + * [view commit](https://github.com/yahoo/elide/commit/15df48408d453340eed0911a1fbc52b21f03a9ef) Bump jedis from 3.5.1 to 3.6.0 (#2106) + * [view commit](https://github.com/yahoo/elide/commit/f0626ea0a2bd5244f85a6211f05681e732d4046b) Bump version.junit from 5.7.1 to 5.7.2 (#2107) + * [view commit](https://github.com/yahoo/elide/commit/607b6cdb5b2f65e3752f1cdea5189404838d2994) Bump metrics.version from 4.1.21 to 4.2.0 (#2109) + * [view commit](https://github.com/yahoo/elide/commit/e1c93d9f34829c4ee142a4919b5fde26502eaa11) Verify table arguments. (#2110) + * [view commit](https://github.com/yahoo/elide/commit/4e62925ecfa7119e5476f74f9acc5058579aac21) Prevent nesting for columns that reference joins that contain $$column references (#2098) + * [view commit](https://github.com/yahoo/elide/commit/27aa83a604ee5a4b6a1be7c5f9ab112ba2e8ae37) Bump mockito-core from 3.9.0 to 3.10.0 (#2111) + * [view commit](https://github.com/yahoo/elide/commit/b2eadb111297a8167f2715e027b9284c765adb32) Verify column arguments. (#2114) + * [view commit](https://github.com/yahoo/elide/commit/2594c061c2b76b2e5258d596f6280b8434b766dc) Code cleanup for elide-model-config (#2115) + * [view commit](https://github.com/yahoo/elide/commit/e1f90ce023aff7b1d0a87250c314a66d240ef418) Bump javassist from 3.27.0-GA to 3.28.0-GA (#2084) + * [view commit](https://github.com/yahoo/elide/commit/4b8310f782f755f20fa79b52b7e4d13480b140f9) Remove usage of SQLReferenceTable. (#2116) + * [view commit](https://github.com/yahoo/elide/commit/5ee0719fee3e24d45a1a8f46b251d11acecbe2f6) Bump spring.boot.version from 2.4.5 to 2.4.6 (#2117) + +## 5.0.0-pr34 +5th public release candidate for Elide 5.0. Major features will likely come in the next release as this release lays foundation for parameterized attributes and Handlebars templating support in HJSON configuration files. + +**Features** + * [view commit](https://github.com/yahoo/elide/commit/4c33506fd7df9e0bbaf19f5d7cfb239c2a27b80e) Add 2 new filter operators - between and notbetween (#1962) + * Hibernate 3 & 5 stores now benefit from same N+1 improvement as JPA store. + +**Configuration Changes** + * [view commit](https://github.com/yahoo/elide/commit/86b7b041643f45a7757d8e621ff213a424a9f4c3) Physical Column Reference within handlebars must start with '$' (#1953) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/f2ed4903b05b83b0e82449a43eda49fefc48dc28) NPE when projection has no permission for relationshipName (#1989) + * [view commit](https://github.com/yahoo/elide/commit/212dcab497c321f67b66d619748b023201f2c819) Remove source from ColumnProjection (#1918) + * [view commit](https://github.com/yahoo/elide/commit/a5a6a45b223a005a2c6fa14e58fb969bbaa21900) Added IT test for numeric value prefix, postfix, infix, and in operators (#1908) + * [view commit](https://github.com/yahoo/elide/commit/0669b000f4c00c539dccbcab3e15e8336653a383) Query Plan Optimizer (Part 2) (#1919) + * [view commit](https://github.com/yahoo/elide/commit/82abcb7f32c5a94d82820bab09609333769d7640) Part 3 of Query Plan Optimization: Extract physical columns from join clause. (#1920) + * [view commit](https://github.com/yahoo/elide/commit/7a11ee300605ed0130d89a036ed221d49f1b1d9c) Agg Store Model Changes for Parameterized Metric Support (#1921) + * [view commit](https://github.com/yahoo/elide/commit/e1e07dbeabb690560754c84200b67e7a8d4d9229) Part 4 of Query Plan Optimization : Fix Metric Nesting (#1924) + * [view commit](https://github.com/yahoo/elide/commit/e891dec78d7a2e0639691c404b33e666b131b2b9) issue 1904 - fix lombok build warnings (#1944) + * [view commit](https://github.com/yahoo/elide/commit/80060c6b960497b346c7325408dcc2459bbdfd70) Fix Lombok EqualsAndHashCode warnings (#1948) + * [view commit](https://github.com/yahoo/elide/commit/ed74d2599328232dcf92da0b249f180ecc56f808) Sonar integration (#1484) + * [view commit](https://github.com/yahoo/elide/commit/c006d4fc8e9e03685c14e93c1499f8317c723d34) Replace all occurrence of '{{}}' within grain expression (#1954) + * [view commit](https://github.com/yahoo/elide/commit/5e18149831ff5101d7c9c07bda69f3bda2ce000e) Elide graphql javadoc warning fixes (#1951) + * [view commit](https://github.com/yahoo/elide/commit/7d1c3c9ef88828725f86e5d6885c649f5ca2619d) Support arguments at dimension, measure, and table level. (#1952) + * [view commit](https://github.com/yahoo/elide/commit/6b9fe00b85461bb3ad17310348cec20ad411512d) Parameterized Metrics: Aggregation Store Model Changes Set 2 (#1957) + * [view commit](https://github.com/yahoo/elide/commit/ed941827e1027083367b4df9794501909279c667) diamonds to reduce duplication (#1947) + * [view commit](https://github.com/yahoo/elide/commit/5aa899ebbc6af4bfb538a4c04deef2218348a178) Issue 1929 - define function as lambda (#1945) + * [view commit](https://github.com/yahoo/elide/commit/dc7a367e14c9f43b2d013f5037341055a29f0491) Issue 1929 - Address Sonar suggestions (#1925) + * [view commit](https://github.com/yahoo/elide/commit/cd008a45ad379004b92c5114150016027aa07b80) Query plan optimizer phase5 (#1961) + * [view commit](https://github.com/yahoo/elide/commit/c30dd7553780d9abcd7d91656a97967789719fed) updated README with elide intro video (#1967) + * [view commit](https://github.com/yahoo/elide/commit/6feae4c15089559bd39693bbdd38a26be1bdd50a) fix build warnings (#1970) + * [view commit](https://github.com/yahoo/elide/commit/2be6ef0a57182e17ed4658f9d4cd2289d8cf2256) Fix elide-model-config javadoc warnings (#1972) + * [view commit](https://github.com/yahoo/elide/commit/67ffeb7e9dc2ee1af46970d7263f11470c47f42b) Fix javadoc to remove warnings (#1966) + * [view commit](https://github.com/yahoo/elide/commit/0e85109b24e6637f46b73d9722fc08cc01cfa639) elide-datastore-aggregation - fix javadoc build warning (#1963) + * [view commit](https://github.com/yahoo/elide/commit/3abe188ed5f45bfc492dafe8637a6bd465a08c6e) Random Async/TableExport IT failure fix (#1974) + * [view commit](https://github.com/yahoo/elide/commit/e51178e12f6ffc6934cf08012fa084ec94499dd0) Test case for ParameterizedMetrics Aggregation Store Model changes (#1968) + * [view commit](https://github.com/yahoo/elide/commit/e72169c59d0d6c679c018901c7009ecf6cc23362) Remove dead code from GraphQLEntityProjectionMaker.java (#1977) + * [view commit](https://github.com/yahoo/elide/commit/4c8d6a4f57bc34f08017de12b1276ebe331b9849) Populate $$user and $$request context (#1975) + * [view commit](https://github.com/yahoo/elide/commit/234905d972c491934ce3774771c9733193b45007) Phase 6: Query Optimization - Calcite parser (#1976) + * [view commit](https://github.com/yahoo/elide/commit/7a0b59711bb5e269afbc6b15426a8aff94a5e447) Removed QueryPlanResolver. Replaced it with MetricProjectionMaker. Small fix to Calcite parser builder to use the SQL conformance of the underlying dialect (#1980) + * [view commit](https://github.com/yahoo/elide/commit/43598e8411d544b31d9807ded4f248e25ef334a4) Add Table Level Query Hints (#1981) + * [view commit](https://github.com/yahoo/elide/commit/737b4ee7d0791a757a496b21f60dd7e8d944f9a0) Phase 8 of Aggregation Store Optimizer: Adding more tests (#1984) + * [view commit](https://github.com/yahoo/elide/commit/530fbe65d758da483c9d17bd82193ce7e94681e4) Add join metadata to SQLTable. (#1988) + * [view commit](https://github.com/yahoo/elide/commit/98f612f023448a93324aaed11e00d6d4fa730c74) Issue 1929 - Clean up code quality (#1964) + * [view commit](https://github.com/yahoo/elide/commit/601aeb158afd8e982f14f0bc2df9681435b6e2c9) Projection may not have permission for relationshipName (#1989) + * [view commit](https://github.com/yahoo/elide/commit/9e510ad89e97906c3f281d25388687bef59c3400) refactor Functional Interfaces (#1990) + * [view commit](https://github.com/yahoo/elide/commit/f4c71e62309d59c533414719294045cd9077684a) refactor test (#1992) + * [view commit](https://github.com/yahoo/elide/commit/4835eabc8bf423267450dc5ef508b1778625befc) Expression reference ast and parser (#1994) + * [view commit](https://github.com/yahoo/elide/commit/bfd3ca6c4bb9f25307c08f7f8e524dba098926dc) bump JPA (#1997) + * [view commit](https://github.com/yahoo/elide/commit/89619d233aa877230f49bf962f979a0af34f941f) Adding explicit support for dialect operators to help determine when column nesting is possible. (#1996) + * [view commit](https://github.com/yahoo/elide/commit/a62e387639d12f06296c9d17ee43d1a11f7841c0) Consolidate DataStoreIT (#1995) + * [view commit](https://github.com/yahoo/elide/commit/699e5df3ae0d3d3f486efde7a643ccb7afc40d7a) Formula Validation using ExpressionParser (#1998) + * [view commit](https://github.com/yahoo/elide/commit/8010ad922008139a80ec6195fabaf5d0f315fba6) TimeDimension Grain Arg match check between Having Filter and Projection (#1999) + * [view commit](https://github.com/yahoo/elide/commit/fc1c8ead8941d48451d7433d5c4fd42d847129ea) reuse isEmpty (#2000) + * [view commit](https://github.com/yahoo/elide/commit/145829ee58b7a168c59ca58d502c629670ab0664) Hibernate jpa (#1993) + * [view commit](https://github.com/yahoo/elide/commit/f4e3038ae54d10e080df1ad69e97efc4000e4d84) Bump metrics.version from 4.1.17 to 4.1.19 (#1987) + * [view commit](https://github.com/yahoo/elide/commit/4c8f99a99858c013981361ce1db21e464acca887) Migration to Maven Central (#2001) + * [view commit](https://github.com/yahoo/elide/commit/5f8082ff008e8725388aca5badcf0d4737e51e06) Bump mockito-junit-jupiter from 3.3.3 to 3.9.0 (#1986) + * [view commit](https://github.com/yahoo/elide/commit/2863cf6abff716e879832bef5cd2d2a2258b0c23) Bump lombok from 1.18.16 to 1.18.20 (#1982) + * [view commit](https://github.com/yahoo/elide/commit/7258368d3333ceb2b12687d6af0af5a5918986be) Optimizer phase11 (#2002) + * [view commit](https://github.com/yahoo/elide/commit/9bdaceffabc7901bdc4b6531dec9e07f850179e5) Bump system-lambda from 1.1.1 to 1.2.0 (#1914) + * [view commit](https://github.com/yahoo/elide/commit/65a2f04292dd42ab99cedcacf66677b8e5ad0c3e) Bump dependency-check-maven from 6.1.1 to 6.1.5 (#1978) + * [view commit](https://github.com/yahoo/elide/commit/0f49a1077dcd0e15ae76aed11345369cd5354f4b) Bump wagon-ssh-external from 3.4.2 to 3.4.3 (#1915) + * [view commit](https://github.com/yahoo/elide/commit/0fc466757ca3489b82a215e9b62c8b32f8263287) Bump classgraph from 4.8.102 to 4.8.104 (#2008) + +## 5.0.0-pr32 +4th public release candidate for Elide 5.0 + +**Features** + * [view commit](https://github.com/yahoo/elide/commit/6c97725cec25be900fd1466f627d3d0e722ed508) N+1 performance improvements in the JPA data store. The JPA store will now return proxied collections (allowing the ORM to batch fetch the collection) and filter, sort, and paginate in memory whenever fetching a collection (N>1) of collections. There is a feature flag to enable/disable this behavior. (#1876) + +**API Changes** + * [view commit](https://github.com/yahoo/elide/commit/a2c66e96df6239b1e545772308aaf4a9b50df80e) RSQL now supports attribute arguments in filter expressions. This will allow fully parameterized attributes. (#1877) + * [view commit](https://github.com/yahoo/elide/commit/1c6cfbe04941b27b9de28cf04084744439c8f5e6) The Aggregation Store now supports filters on metrics that have not been requested/projected in the client request. (#1897) + +**Interface Changes** +The following changes were made to make it easier to migrate from Elide 4: + + * [view commit](https://github.com/yahoo/elide/commit/7fb6049eaa3e53145b5b619668577a3493d60d3e) Added back support for legacy life cycle annotations (#1875) + * [view commit](https://github.com/yahoo/elide/commit/f1ed11a1eb4eedc758615352ce8833d50a03386a) Added flag to force OperationChecks to run at transaction commit. + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/852ce04c8dffb0673577d91d0b7e5763f79c52e0) Async IT cleanup (#1855) + * [view commit](https://github.com/yahoo/elide/commit/e2005d6babde1a0da3ba66fc507fc6ecf5773052) Cleanup unnecessary syntax (#1854) + * [view commit](https://github.com/yahoo/elide/commit/8338d3d8de7b5b1b06d27dd2218842db983ed8df) Bump dependency-check-maven from 5.3.2 to 6.1.1 (#1863) + * [view commit](https://github.com/yahoo/elide/commit/6c428b39f2d9cd0e710ee242ee295ec9038680a9) Bump version.jackson from 2.11.3 to 2.12.1 (#1758) + * [view commit](https://github.com/yahoo/elide/commit/59f0c3cafed60c462b82e13e45cd0664afa7b7cf) Cleanup tests (#1856) + * [view commit](https://github.com/yahoo/elide/commit/8dcbfc52f383eea2b5b5ca198585d5f85077c854) Move Include to package-info (#1853) + * [view commit](https://github.com/yahoo/elide/commit/6a959198974a1ea8c7ae6919a8dc4923a4aa0e8d) Fix Config Path to work correctly with Windows (#1865) + * [view commit](https://github.com/yahoo/elide/commit/9fc8e7e8a7d19f1daf708deb099ae09a33ecbc9c) use pipeline cache (#1869) + * [view commit](https://github.com/yahoo/elide/commit/3a131fcc3038464be94d8a9a0d79b1e6c48c12ee) Export Controller Disable on Async Disabled or Export Disabled (#1868) + * [view commit](https://github.com/yahoo/elide/commit/81f658e6ac86ecc9c139e47f8d265fb63b038d48) Bump rxjava from 2.2.20 to 2.2.21 (#1874) + * [view commit](https://github.com/yahoo/elide/commit/93b4c7fce13375d6c93cf2725ab493ce915f6cc1) bump jetty to 9.4.38.v20210224 for CVE-2020-27223 (#1883) + * [view commit](https://github.com/yahoo/elide/commit/36fb16341927b92f9b134e4bde1915e7b33f5e65) Correct exception logging (#1882) + * [view commit](https://github.com/yahoo/elide/commit/8274a6341e06c7201049e8e8f0a48f07bd99a0ef) Cleanup IDE warnings (#1894) + * [view commit](https://github.com/yahoo/elide/commit/d1407c5163212af8f68cbe4e08e335a691e49c82) try-resource block to avoid resource leak (#1898) + * [view commit](https://github.com/yahoo/elide/commit/71e37b70fc4e06b595d633ec6e8a930469c059f2) Removed the duplicate entry for dependency - javax.persistence-api in pom.xml (#1895) + * [view commit](https://github.com/yahoo/elide/commit/bb9afc3d59dd98794601d5fdd039d4f557a2f904) Bump hibernate-search-orm from 5.11.7.Final to 5.11.8.Final (#1878) + * [view commit](https://github.com/yahoo/elide/commit/51e3b057673ad8ec218d888f0225bd8f9c930009) Bump guice from 4.2.3 to 5.0.1 (#1872) + * [view commit](https://github.com/yahoo/elide/commit/adad62718648be6518cb035262ba4eb58ab285a7) refactor async IT and Fix alias in JSON format (#1870) + * [view commit](https://github.com/yahoo/elide/commit/8cf5aeae76f18014a7df0a7e3def703d97ab594f) Bump version.antlr4 from 4.9 to 4.9.1 (#1880) + * [view commit](https://github.com/yahoo/elide/commit/5c7c32d2ab11b27470042513fbfbafc49cf81124) Bump version.jackson from 2.12.1 to 2.12.2 (#1879) + * [view commit](https://github.com/yahoo/elide/commit/b73d1d9ee7cf79c3661badbe017f452b3ce30109) Bump checkstyle from 8.37 to 8.41 (#1871) + * [view commit](https://github.com/yahoo/elide/commit/c19d8c2237e960d792dc2ef6375356433b4fac21) Classtype cleanup (#1905) + * [view commit](https://github.com/yahoo/elide/commit/d31f74535e2e0f67a1d0fa40d33b43f9d49e66df) Bump maven-checkstyle-plugin from 3.1.1 to 3.1.2 (#1860) + * [view commit](https://github.com/yahoo/elide/commit/b8bbc362545cdf9f613508799e3a012d9f4c2b8d) Bump micrometer-core from 1.5.6 to 1.6.4 (#1859) + * [view commit](https://github.com/yahoo/elide/commit/0817c848724a86e4a8b11afa1b5f81966fcaf09e) Bump HikariCP from 3.4.5 to 4.0.2 (#1857) + +## 5.0.0-pr31 +3rd public release candidate for Elide 5.0 + +**Features** + * 'hasmember' and 'hasnomember' operators now work across toMany relationships. + * elide-async includes a data export API in CSV and JSON. + +**API Changes** + * Time dimensions (Aggregation Store) now have support for multiple time grains that can be selected by the client at query time. + * Invalid sparse fields returns a 4xx error to the requesting client. + +**Interface Changes** + * `JPQLPredicateGenerator` has a new contract allowing the generation of more complex JPQL expressions. + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/acb9e3047db287f6d6503abbaf83befbef04f209) Added javadoc and small improvements to new Elide types. (#1783) + * [view commit](https://github.com/yahoo/elide/commit/9ce54149fec0e36da281673da40f586068f6459b) Passing query parameters and headers to patch extension request scope (#1781) + * [view commit](https://github.com/yahoo/elide/commit/b012115bd4753517196d4305d8002a96b3e76c57) add join expression for all column projection including metrics (#1789) + * [view commit](https://github.com/yahoo/elide/commit/2952559ba91b092b0bd1a51fb84f7c2b69cb937e) Field name must start with lower case (#1799) + * [view commit](https://github.com/yahoo/elide/commit/e5354ea169d10ed13d4af00b0cca149b37ba3b1d) Add core support for parameterized attributes. (#1800) + * [view commit](https://github.com/yahoo/elide/commit/37a7860a067379dcee8831bfc58f816d3ee82dd6) Issue#1779 Invalid sparse fields should return 4xx (#1801) + * [view commit](https://github.com/yahoo/elide/commit/1d04e914c89ad894560fe36d8d11e96fb2774981) Async Refactor: Part 1 (#1777) + * [view commit](https://github.com/yahoo/elide/commit/93ebb969254204bd315de860bd064ca77e70cfab) TableExportHook Binding (#1802) + * [view commit](https://github.com/yahoo/elide/commit/4cc5a72bf152cc34c1d489f69221b8797f36ee38) Remove jetty from starter (#1807) + * [view commit](https://github.com/yahoo/elide/commit/dd69d567f445b9a62dda30b9ff8093ea4455c08b) Async Refactor Cleanup (#1804) + * [view commit](https://github.com/yahoo/elide/commit/c3a735c617807ab7d947103b442caadb08172524) Bump ant from 1.10.8 to 1.10.9 in /elide-graphql (#1810) + * [view commit](https://github.com/yahoo/elide/commit/ba73bc2a5617fed8feeee5b419a0f0808081628f) Elide 5 Agg Store: Multiple time grains (#1806) + * [view commit](https://github.com/yahoo/elide/commit/994c0a865027795133ac5731ff4b47b4f910ab4e) Fix for FieldType in FilterPredicate (#1814) + * [view commit](https://github.com/yahoo/elide/commit/7a162860be33acca6cb6fc8109a8e9071497e03e) Metadatastore Models Permission (#1816) + * [view commit](https://github.com/yahoo/elide/commit/34fbcd282798c6668f61ff35202c3c2aa783857f) TableExport Spring Controller (#1811) + * [view commit](https://github.com/yahoo/elide/commit/dcb6336070c158b923a8e5a7ec92502ea57f36ee) Add dynamic table type (#1815) + * [view commit](https://github.com/yahoo/elide/commit/84688c81b2020355f55e03250146a2a3cb515484) Bump junit-platform-launcher from 1.6.0 to 1.7.1 (#1819) + * [view commit](https://github.com/yahoo/elide/commit/d1be9578498603d9b0f73161cc09555c5c34d3d6) Convert user provided alias before using in SQL (#1821) + * [view commit](https://github.com/yahoo/elide/commit/c02e0d107dc5236626d4a08cafbae749f7111ce5) updating contrib guidelines for hackathon (#1826) + * [view commit](https://github.com/yahoo/elide/commit/0db416741427385c77840648e2001a9f776a7b28) Bump spring.boot.version from 2.4.1 to 2.4.2 (#1796) + * [view commit](https://github.com/yahoo/elide/commit/5186f34a1f9d82129294790c3055aa3ae5a69ba6) Update CONTRIBUTING.md (#1827) + * [view commit](https://github.com/yahoo/elide/commit/00509f41da2d821ef0464c6a0e762c2e09c50be7) Async: Test Coverage (#1825) + * [view commit](https://github.com/yahoo/elide/commit/106bb161fbeb937b15a19dd40f4ab9d17037ed6d) Export : Standalone API Resource (#1817) + * [view commit](https://github.com/yahoo/elide/commit/9f09fb8c90ab84bfd4efd8dc0dba3a883c03061c) Update CONTRIBUTING.md (#1828) + * [view commit](https://github.com/yahoo/elide/commit/d77f1240a3fa5abf824aea54d0533be844235359) Created new interface for dynamic configuration (#1830) + * [view commit](https://github.com/yahoo/elide/commit/5283569e556181cdc145f0090eac74ee6a9d3e1c) Issue#1798 Remove Singleton Pattern for Async Service Classes (#1831) + * [view commit](https://github.com/yahoo/elide/commit/1758431ef49e845f6b515b6961afb3bd3629fcdb) Bump jedis from 3.3.0 to 3.5.1 (#1794) + * [view commit](https://github.com/yahoo/elide/commit/e3568815ab6eec1471c32adcc3272afbc139eab4) Data Export: JSON API support (#1824) + * [view commit](https://github.com/yahoo/elide/commit/39d1add6c7f756aae279889bf3574594c1f76cda) Bump wagon-ssh-external from 3.4.0 to 3.4.2 (#1793) + * [view commit](https://github.com/yahoo/elide/commit/66be9ae2277fe7c3ea21edc1a701cc8ab498a22e) Validators for JSON and Graphql Exporter (#1833) + * [view commit](https://github.com/yahoo/elide/commit/4f24e6aed8daa025835a15765e515a2aa5f3e47a) Revising JPQPredicateGenerator contract to allow for more complex JPQ… (#1834) + * [view commit](https://github.com/yahoo/elide/commit/af0aad2498f81659177eff1bbcfbf49213baf7b9) Exclude Models based on Conditions (#1835) + * [view commit](https://github.com/yahoo/elide/commit/4387ca571472cddf2f33b5699fd41f259cf94cec) Add 'hasmember' operator support across to-many relationships. (#1843) + * [view commit](https://github.com/yahoo/elide/commit/da92f9c81b30848fa05c1052e50782e0ddeb47c7) added one more hasmember operator IT test (#1844) + * [view commit](https://github.com/yahoo/elide/commit/8b7b7a7c86f0d149977a58784422ab531aed2859) Bump version.jetty from 9.4.35.v20201120 to 9.4.36.v20210114 (#1840) + * [view commit](https://github.com/yahoo/elide/commit/4b1501cba9dcf1ee8f67b97a3b0aa7261b950836) Bump classgraph from 4.8.98 to 4.8.102 (#1836) + * [view commit](https://github.com/yahoo/elide/commit/e6e2013d41a05eca5567b9ca4b099afdfb57dd1c) Bump version.junit from 5.7.0 to 5.7.1 (#1837) + * [view commit](https://github.com/yahoo/elide/commit/f04b48379a22983128c1e0654808bbdc1276f43f) Bump metrics.version from 4.1.16 to 4.1.17 (#1838) + * [view commit](https://github.com/yahoo/elide/commit/1d0b2760e334815e58c1e9992cd2ef689dd61e2e) Bump hk2-api from 2.5.0 to 3.0.1 (#1759) + * [view commit](https://github.com/yahoo/elide/commit/2eb90f1b8513e246930e5419e838b3fb1f7bbb98) Integration Tests for Table Export (#1842) + * [view commit](https://github.com/yahoo/elide/commit/cea07da78f49da8b63328bd838962c70f8330228) Small Fix - Removing system.out from TableExport (#1850) + * [view commit](https://github.com/yahoo/elide/commit/34d521aaf2c268ab23a48f155fbfcd012470312b) Removing mysql connector as a dependency (no longer needed) (#1848) + * [view commit](https://github.com/yahoo/elide/commit/49501c10202064527e0d31c3030fe12a4a4d710b) Fixing the regular expression for required filter templates (#1849) + * [view commit](https://github.com/yahoo/elide/commit/b7e708537fc0c5fde20c6c1ae515f028b29141ed) Small Fix (#1851) + +## 5.0.0-pr30 +2nd public release candidate for Elide 5.0. + +**Features** + * New Apache Druid dialect for the Aggregation data store. + +**Interface Changes** +We hope this will be the last round of interface changes: + * Fixing #698 - Migrating from Object to Java Generics for DataStoreTransaction. + * Introducing a new `Type` abstraction for Elide models to eventually allow dynamic model registration at runtime. The new Type abstraction changes some core interfaces for Security and Data Stores. + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/7583e0633d31cbbc561ec7253c19a42c4f07cee3) [Security] Bump hibernate-validator from 6.0.2.Final to 6.1.0.Final + * [view commit](https://github.com/yahoo/elide/commit/b95380261a97f46eac99420b606dcd12b438e11b) Bump guice from 4.2.2 to 4.2.3 + * [view commit](https://github.com/yahoo/elide/commit/b45bce669cc7beb1ae03044f78f647e13fb75d52) Bump classgraph from 4.8.90 to 4.8.95 + * [view commit](https://github.com/yahoo/elide/commit/2b46cb4c9b1392553e67d0e9f94112c867199fac) Bump groovy.version from 3.0.6 to 3.0.7 + * [view commit](https://github.com/yahoo/elide/commit/a6e66f40c73a27ecc9ac806d51e199b0725f2aa7) Bump version.junit from 5.6.2 to 5.7.0 + * [view commit](https://github.com/yahoo/elide/commit/ecc73c6719030d5fdf1393b6de7c7afd894a4425) Upgrade spring.boot version + * [view commit](https://github.com/yahoo/elide/commit/6bef7a67718a8cb4df7d14dce5a307acfe36f8c9) Bump metrics.version from 4.1.14 to 4.1.16 + * [view commit](https://github.com/yahoo/elide/commit/c458e53f829586b81f7f896bf32e6a1526358b99) Bump mockito-core from 3.6.0 to 3.6.28 + * [view commit](https://github.com/yahoo/elide/commit/29064bbf520f2259f5df5bf122bb1fef65c4accb) Updating jetty version for security issue + * [view commit](https://github.com/yahoo/elide/commit/703927eb86d02ceed5916e05bc303b7635d189ef) Closes #1740 More meaningful handling of constraint violations + * [view commit](https://github.com/yahoo/elide/commit/d286bca4ff68b630dec6f5ea5cbe10ab88b6c674) Add suppresion for hibernate 3 CVE + * [view commit](https://github.com/yahoo/elide/commit/03cbd10231cf444968c020453215a46d68ee83e0) SD Build Status Badge + * [view commit](https://github.com/yahoo/elide/commit/d4205b4ac5d70be4edb89b8b5ad4ec9841b8632b) Issue#1697 Validate Query Parameters + * [view commit](https://github.com/yahoo/elide/commit/b3a904f5c830d2d14df18a7a77820cd588b78cb5) Bump version.antlr4 from 4.8-1 to 4.9 + * [view commit](https://github.com/yahoo/elide/commit/392a5df115726de79c4c38025266adf3e68e7de2) Bump version.restassured from 4.3.2 to 4.3.3 + * [view commit](https://github.com/yahoo/elide/commit/7f61879e6666cc041c5535ec36f5e1fef54ff6c7) Bump classgraph from 4.8.95 to 4.8.98 + * [view commit](https://github.com/yahoo/elide/commit/fbc06b8e090d1d7438794b879ebbb3f16c59e66a) Speeding up swagger builder on service boot + * [view commit](https://github.com/yahoo/elide/commit/7dcb449ad9bf24c3c12601209803a0f05420e380) Only build swagger if the controller is activated + * [view commit](https://github.com/yahoo/elide/commit/6ce117b6c68f6a2fc484e3c9ba7b76ace9c596ff) Added baseUrl configuration setting for Spring and Standalone + * [view commit](https://github.com/yahoo/elide/commit/172f3833738466c50648a2f4c3644eae46706aa2) Issue#1750 Fix SQL query generation for record count + * [view commit](https://github.com/yahoo/elide/commit/ff9d0524ff9895196010c130a9ecdb2e6de71177) Bump commons-io from 2.6 to 2.8.0 + +## 5.0.0-pr29 +For 4.x line release notes, please check out this file on the [elide-4.x branch](https://github.com/yahoo/elide/blob/elide-4.x/changelog.md). + +PR29 is the first public release candidate for Elide 5.0 + +**Features** +Elide 5 introduces three primary new features: + + * A new semantic modeling layer and analytic query API for OLAP style queries against your database. + * An asynchronous API for API read requests with long durations. + * A mechanism to version elide models and the corresponding API. + +**API Changes** +The only notable API change are: + + * Improved error responses that are more compatible with the JSON-API specification. + * FIQL operators are now case sensitive by default. New case insensitive operators have been introduced allowing greater flexibility. It is possible to revert to elide 4 semantics through configuration. + +**Interface Changes** +In addition to new features, Elide 5 streamlines a number of public interfaces to simplify concepts. This includes: + + * A simpler Check class hierarchy. + * A new NonTransferable permission (which replaces SharePermission). + * Changes to Elide’s User abstraction for authentication. + * Lifecycle hooks have been restructured to better decouple their logic from Elide models. + * Initializers have been removed. Dependency Injection is available for models, checks, lifecycle hooks, and serdes. + * A simpler and more powerful DataStoreTransaction interface. + * GraphQL has its own FilterDialect interface. + * The Include annotation now defaults to marking models as root level. + * Elide settings has been stripped of unnecessary configuration options. + +**Module & Package Changes** +Because Elide 5 is a major release, we took time to reorganize the module & package structure including: + + * elide-example has been removed. The only Elide examples we plan to maintain are the spring boot and standalone examples. + * elide-contrib submodules have been promoted to mainline modules elide-swagger and elide-test. + * elide-annotations has been absorbed into elide-core. + * New modules were created for elide-async (async API), elide-model-config (the semantic layer), and elide-datastore/elide-datastore-aggregation (the analytics module). + * Some classes in elide-core were reorganized into new packages. + +## 4.8.0 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/233591723cd04a3b6da54c5234a824712ca613b4) (Similar to https://github.com/yahoo/elide/pull/2342) Revised JSON-API path grammar to accept colon, space, and ampersand in ID fields (#2344) + * [view commit](https://github.com/yahoo/elide/commit/f68bbce9a535e320929440b2408ea74b4c9edffc) Updating screwdriver build + * [view commit](https://github.com/yahoo/elide/commit/69b3edc7f935a39133ced3a97a9a496d58cfafcc) Disabling OWASP checks for legacy build (Elide 4) + +## 4.7.2 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/eeab22ce7cc591c9747b40e509fea4a77e56c8af) Removed elide-example module + +## 4.7.1 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/032894eb40944db1d16a9d5090128aa7e3561df6) bump dependency versions (#1884) + * [view commit](https://github.com/yahoo/elide/commit/707ef1390261c85fbe4631388024af4ec303004f) Modifications from pom on master (#2028) + * [view commit](https://github.com/yahoo/elide/commit/18cd1d67507956cb7c11829dcbb766969f2987b2) Fix bug in filter pushdown extractor (#2030) + * [view commit](https://github.com/yahoo/elide/commit/7dcb3df09527b0e1f79585ee5b23b8644b578403) normalize permission expression before converting to filter expression (#2039) + * [view commit](https://github.com/yahoo/elide/commit/38e66ac9fc59c7694de382ecdbe4a3bae356d88e) sonar (#2044) + * [view commit](https://github.com/yahoo/elide/commit/9788e6cdd4f335d3c071630ed5d1f81be188e9d6) Add ability to handle conversion of Date instances to ISO8601DateSerde (#2046) + +## 4.7.0 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/ec8e6602cc1a8183079f18ae98ed753625690e65) Removing jetty from spring boot starter (#1805) + * [view commit](https://github.com/yahoo/elide/commit/eccbdbc87e7bba04c02d3865fbf605daf1d724c6) Upgrading ant on elide-4.x for CVE (#1813) + +## 4.6.11 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/9ada9013455397b64ce113d9129cbfa67e941467) Adding request query parameters to patch extension request scope (#1782) + +## 4.6.10 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/f0b3e00049800431b361d9b6f66436dde37f0161) Fixed generated relationship URLs for JSON-API Links in Elide 4 + +## 4.6.9 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/e1be594a896dad79cc7c8011e3ba92750d36455a) Adding simple setting to enable JSON-API links + * [view commit](https://github.com/yahoo/elide/commit/42f2bd55c381dfcba2c971413b84a025a7cc5dac) Adding baseURL setting for JSON-API links for Elide 4 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/97791f75648a34204914961e1700ebb7710b3429) Bump checkstyle from 8.36.1 to 8.36.2 (#1559) + * [view commit](https://github.com/yahoo/elide/commit/8a93716912da746d875799e646084010ad518319) Bump mockito-core from 3.5.11 to 3.5.13 (#1558) + * [view commit](https://github.com/yahoo/elide/commit/7c80d9a972150ffe9f5fcdb400f440a8facd2628) Bump jersey-container-jetty-servlet from 2.31 to 2.32 (#1560) + * [view commit](https://github.com/yahoo/elide/commit/22005b59fe72f03dc3d74f1d0aa09a952c15e3b5) Bump version.jersey from 2.31 to 2.32 (#1557) + * [view commit](https://github.com/yahoo/elide/commit/43e6598eb1c3d60ae04925e6e89795867d7bac34) Bump version.jackson from 2.11.2 to 2.11.3 (#1570) + * [view commit](https://github.com/yahoo/elide/commit/fe5787ee2a760925e75a3c781e7282e637a8adb4) Bump version.jetty from 9.4.31.v20200723 to 9.4.32.v20200930 (#1571) + * [view commit](https://github.com/yahoo/elide/commit/41a349efbe8a53638e9c80f91d184364cc7b440f) Bump groovy.version from 3.0.5 to 3.0.6 (#1573) + * [view commit](https://github.com/yahoo/elide/commit/94bf7ec54ae7a9db2f8cd2f3916a91e3fee1cf27) Bump lombok from 1.18.12 to 1.18.14 (#1589) + * [view commit](https://github.com/yahoo/elide/commit/733ec09842f7a38ae7851b0a67fa3ac67855ebe6) Bump liquibase-core from 4.0.0 to 4.1.1 (#1599) + * [view commit](https://github.com/yahoo/elide/commit/33ad32bba3a1ed1a0e8172415aa0c3b47aec39a0) Bump rxjava from 2.2.19 to 2.2.20 (#1588) + * [view commit](https://github.com/yahoo/elide/commit/210d18d12e1deaefe26918727fe05a61a6ec87a1) Bump lombok from 1.18.14 to 1.18.16 (#1600) + * [view commit](https://github.com/yahoo/elide/commit/70c2d5a4eb8ad12613e1304918ec5e94cd7d37a0) Bump mysql-connector-java from 8.0.21 to 8.0.22 (#1601) + * [view commit](https://github.com/yahoo/elide/commit/6915ec99a2df7a0cb6c9949f09f036176da1ee3f) Bump postgresql from 42.2.16 to 42.2.18 (#1598) + * [view commit](https://github.com/yahoo/elide/commit/bb17ebe5716f3691e7cf4ffbf42924e09aedac40) Bump metrics.version from 4.1.12.1 to 4.1.13 (#1587) + * [view commit](https://github.com/yahoo/elide/commit/2900a5f3d3335e58fe3fdf2f967978c285ab54dd) Bump jersey-container-servlet-core from 2.31 to 2.32 (#1556) + * [view commit](https://github.com/yahoo/elide/commit/e9827162fc319685cdd1e4f3fd672c7b1d6c7a95) Bump mockito-core from 3.5.13 to 3.6.0 (#1613) + * [view commit](https://github.com/yahoo/elide/commit/ff18972f729914cc9693437c24614c137a7b32a4) Bump metrics.version from 4.1.13 to 4.1.14 (#1610) + * [view commit](https://github.com/yahoo/elide/commit/77ce02abae1529914ed4e5eb6f7b896fa00673ba) Migrating build to screwdriver (#1631) + * [view commit](https://github.com/yahoo/elide/commit/271cadc63554b5543ada5c7b95822d2f405364ff) [Security] Bump version.jetty from 9.4.32.v20200930 to 9.4.34.v20201102 (#1635) + * [view commit](https://github.com/yahoo/elide/commit/e14c3fb136900171a392092a9bd9569dab1cddbb) Fixing broken links (#1642) + * [view commit](https://github.com/yahoo/elide/commit/f8f65b95b764dee30e1b166b310506af8ae46950) Bump encoder from 1.2.2 to 1.2.3 (#1651) + * [view commit](https://github.com/yahoo/elide/commit/6e20980990413c3f1749d1228b1037845e944cc2) Bump version.restassured from 4.3.1 to 4.3.2 (#1650) + * [view commit](https://github.com/yahoo/elide/commit/78f464a6c04608c2657b56fb18245471078fd3b2) Bump jansi from 1.14 to 2.0.1 (#1627) + * [view commit](https://github.com/yahoo/elide/commit/597634d31b1c610f91c41a0e81adee134954fc18) Bump checkstyle from 8.36.2 to 8.37 (#1625) + * [view commit](https://github.com/yahoo/elide/commit/1dfbc5ca3df5d0082f58675167efd6ad51d7dafc) Bump resteasy.version from 3.13.1.Final to 3.13.2.Final (#1572) + * [view commit](https://github.com/yahoo/elide/commit/c9c3c3851ff3fdda0479461eeba483fca4dd2f82) Bumped packages to fix CVE errors in Elide 4 + * [view commit](https://github.com/yahoo/elide/commit/75fa9b0b459368616c6f16e84022a9154539a2a9) prototype code + * [view commit](https://github.com/yahoo/elide/commit/6b267a01b9e35f645652dd900274ca779dbe5832) Improved performance by reducing N squared computation + * [view commit](https://github.com/yahoo/elide/commit/cd69d666f5a3031f3d48d9b02bd58466a44cc3ec) Improved boot time for swagger builder after profiling. Also bumped guava for CVE. + * [view commit](https://github.com/yahoo/elide/commit/2966c2974fbb3f7d2951d37911f9ea7a4aa969b0) Updated screwdriver yaml + * [view commit](https://github.com/yahoo/elide/commit/d17a1e27169b388d6421b8aa91b50acd10a9b79f) A few more fixes + * [view commit](https://github.com/yahoo/elide/commit/5815249af02f7217329f3e5543c34771801a9f36) Added baseURL for GraphQLController + +## 4.6.8 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/3d3c831696401e26a24d254fa1158675a23adca1) H2 MVCC setting is removed (#1483) + * [view commit](https://github.com/yahoo/elide/commit/0245327d6f0c4bb4f04a0bba18fe9fc3f4b64287) bump H2 (#1489) + * [view commit](https://github.com/yahoo/elide/commit/d66cde4834b0f59ce20e433c6a5a87029751d1fb) path matcher (#1482) + * [view commit](https://github.com/yahoo/elide/commit/fbb619bd743c93a2238f8c5a068438ec8f9c79fc) escape quotes in quoted strings (#1481) + * [view commit](https://github.com/yahoo/elide/commit/689be5026a6f0b7157e1606d9150242eac843117) Provide backward compatible methods for baseUrlEndPoint changes. (#1510) + * [view commit](https://github.com/yahoo/elide/commit/95eebf4b19a292030803ede58841fc63ed22d093) Bump mockito-core from 3.4.4 to 3.5.0 (#1508) + * [view commit](https://github.com/yahoo/elide/commit/868c3d50fdca44c29482d2319c7831a9efdda8ab) Bump postgresql from 42.2.14 to 42.2.16 (#1513) + * [view commit](https://github.com/yahoo/elide/commit/0e77f59daa4fd45918acce4cabd5e887b2ed8aaa) Bump mockito-core from 3.5.0 to 3.5.5 (#1514) + * [view commit](https://github.com/yahoo/elide/commit/43636e8c839fb88213dcd81cb64911a3a77f4735) Bump metrics.version from 4.1.11 to 4.1.12.1 (#1485) + * [view commit](https://github.com/yahoo/elide/commit/a07be579df2b2f8418d63e81d8b3a77579be9e79) Bump version.jackson from 2.11.1 to 2.11.2 (#1476) + * [view commit](https://github.com/yahoo/elide/commit/c275b0175dabd4e47ff2fb1e80b9683576c2121b) Bump version.jetty from 9.4.30.v20200611 to 9.4.31.v20200723 (#1475) + * [view commit](https://github.com/yahoo/elide/commit/3ce7b5702e5d50db3ca64aa190918cb0c3088fa3) Bump mockito-core from 3.5.5 to 3.5.10 (#1522) + * [view commit](https://github.com/yahoo/elide/commit/c66e195e7460fff576c1d17c76a253103a18f9ac) Bump classgraph from 4.8.87 to 4.8.90 (#1530) + * [view commit](https://github.com/yahoo/elide/commit/dc92e7e3c4025a1145b8f9b9122feb84fe775728) Bump checkstyle from 8.35 to 8.36 (#1518) + * [view commit](https://github.com/yahoo/elide/commit/c581212008369d045e189c7a27115cc695280b51) Fixes #1520 by using explicit left join when sorting over relationships (#1521) + * [view commit](https://github.com/yahoo/elide/commit/f7277bd15c1488ca9fd625958e1879b3f71c4ba5) Bump resteasy.version from 3.13.0.Final to 3.13.1.Final (#1536) + * [view commit](https://github.com/yahoo/elide/commit/5441813ab5686ae6624f72f15e49a9b5faf72421) Bump version.junit from 5.6.2 to 5.7.0 (#1535) + * [view commit](https://github.com/yahoo/elide/commit/534a158add89f453b0bdd55c81cc1e1a1f21adf6) Bump checkstyle from 8.36 to 8.36.1 (#1534) + * [view commit](https://github.com/yahoo/elide/commit/9f306905c95d96c4aeea6fcca8d5c75d09a64362) Bump spring-boot-dependencies from 2.3.2.RELEASE to 2.3.3.RELEASE (#1507) + * [view commit](https://github.com/yahoo/elide/commit/c6036da5273c5d246105932f2b56ae82230f8adc) Bump mockito-core from 3.5.10 to 3.5.11 (#1543) + * [view commit](https://github.com/yahoo/elide/commit/3e0a29b2d66ac641b24ee46efcf829f2e5638de7) Bump spring-boot-dependencies from 2.3.3.RELEASE to 2.3.4.RELEASE (#1542) + * [view commit](https://github.com/yahoo/elide/commit/bed08454508138cb0383e3c43dbb81da7e4740b7) Bump jacoco-maven-plugin from 0.8.5 to 0.8.6 (#1540) + +## 4.6.7 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/6ae9bd3623247f0da12c81798327abdb217c29ab) Added support for JSON-API links in entity response. (#1445) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/e86b48c5abf1f13478279515ac21d4f4df5c56e9) Process collection in operator getFieldValue (#1427) + * [view commit](https://github.com/yahoo/elide/commit/7a62b3f0b5db5585ba1fd937c00d454ee047167e) Only issue page total query if data load returns records (#1429) + * [view commit](https://github.com/yahoo/elide/commit/cafbd29d62b8878225530830ff107f67c9c341e1) Convert DateTimeParseException into IllegalArgumentException (#1440) + * [view commit](https://github.com/yahoo/elide/commit/2925b681cb8defbcaa8067782945abcec1dcbcc2) make ThreadLocal usage static as per recommendations (#1441) + * [view commit](https://github.com/yahoo/elide/commit/d36b17b973c9095b3c9df04eb44519da5f1e55cf) Bump liquibase-core from 3.10.0 to 3.10.1 (#1439) + * [view commit](https://github.com/yahoo/elide/commit/858d33dacf84494b3e75604b9ba6df3d50d5b6c6) Bump swagger-core from 1.6.1 to 1.6.2 (#1438) + * [view commit](https://github.com/yahoo/elide/commit/066b5d613e0c4c099117415eb379f964f62329d8) Bump mysql-connector-java from 8.0.20 to 8.0.21 (#1437) + * [view commit](https://github.com/yahoo/elide/commit/e17877cb67226f7ec8db2a857ccb53b93b2d3b2d) Bump checkstyle from 8.33 to 8.34 (#1436) + * [view commit](https://github.com/yahoo/elide/commit/ce8fbe7afa83609bffd1d892efb9c251ea385864) Bump jackson-module-jaxb-annotations from 2.11.0 to 2.11.1 (#1435) + * [view commit](https://github.com/yahoo/elide/commit/eecb5641c8109c34dcc3e72a82b5225311702f2b) Bump version.jackson from 2.10.4 to 2.11.1 (#1413) + * [view commit](https://github.com/yahoo/elide/commit/0061d91771b0b668edb60126e99fa6b21c1a7c26) Bump resteasy.version from 3.12.0.Final to 3.12.1.Final (#1433) + * [view commit](https://github.com/yahoo/elide/commit/7ceb68872081104943bf69936b59365c950a87d7) Bump metrics.version from 4.1.9 to 4.1.10.1 (#1434) + * [view commit](https://github.com/yahoo/elide/commit/add535fc209e36e9a9a4791c299187802869144e) Using spring's dependency management (#1442) + * [view commit](https://github.com/yahoo/elide/commit/d6b46aefd442179ef43377daf851711b61e68089) Fix for Graphql Fragment Error in Debug Mode - Elide 4.x (#1446) + * [view commit](https://github.com/yahoo/elide/commit/11ee675dd9545f7226a7ba658886ddd748946458) Fix CVE errors 21-Jul (#1457) + * [view commit](https://github.com/yahoo/elide/commit/bc5fac28101c57d31cba58948b2929351f0a74c3) Bump commons-lang3 from 3.10 to 3.11 (#1456) + * [view commit](https://github.com/yahoo/elide/commit/ca7c84d7b18871f1a20766769595b03bb140bf93) Bump groovy.version from 3.0.2 to 3.0.5 (#1458) + * [view commit](https://github.com/yahoo/elide/commit/7ae7144f4d304544364355fedca9cfaf3ccbdc01) Bump liquibase-core from 3.10.1 to 4.0.0 (#1455) + * [view commit](https://github.com/yahoo/elide/commit/bf6ed0af30e7019fd9d10b2082a4aee021abb6d2) Bump metrics.version from 4.1.10.1 to 4.1.11 (#1453) + * [view commit](https://github.com/yahoo/elide/commit/53be250e7b7d4e66610cbb1e426b11893707ef15) Bump mockito-core from 3.3.3 to 3.4.4 (#1452) + * [view commit](https://github.com/yahoo/elide/commit/02cb0de1c2dea224e5bcb9c10d86466485206ee8) Bump version.restassured from 4.3.0 to 4.3.1 (#1451) + * [view commit](https://github.com/yahoo/elide/commit/305cea366065d0ea46972ffef4f583e6aeb85ed3) Bump resteasy.version from 3.12.1.Final to 3.13.0.Final (#1450) + * [view commit](https://github.com/yahoo/elide/commit/afe843d9a888f7b015575c0cb389f1c6275ce9ad) Resolves #1461 (#1463) + * [view commit](https://github.com/yahoo/elide/commit/5bc03c686721c8f4c2574acd539e644b60ee8010) Bump checkstyle from 8.34 to 8.35 (#1467) + * [view commit](https://github.com/yahoo/elide/commit/95db95f92482eb102d7337d8af9e20624a436b5b) Bump spring-boot-dependencies from 2.3.1.RELEASE to 2.3.2.RELEASE (#1466) + +## 4.6.6 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/4e06a82860911d870863bd3fb2cad94753b55ed2) Add support for composite IDs. Issue756 (#1412) + * [view commit](https://github.com/yahoo/elide/commit/3d229393b37023da2df7ef481a199da613aaf925) Add support for ISO8601 to java.time.Instant serialization/deserialization (#1417) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/4edac24dee840bfe9f6bbf331ecbec4042970712) add queryParams variants for post(), patch() and delete() (#1411) + * [view commit](https://github.com/yahoo/elide/commit/64fb49abcdbd0d683ef9dc4cb7b8dd30adab48b6) Bump ant from 1.10.7 to 1.10.8 (#1394) + * [view commit](https://github.com/yahoo/elide/commit/51aa322e9b069f990ea626058e26c42d412399d5) Bump maven-shade-plugin from 3.2.3 to 3.2.4 (#1396) + * [view commit](https://github.com/yahoo/elide/commit/7681ec1db3e516c85cc10b4948b4ff873b6e2d4d) Bump tomcat.version from 9.0.35 to 9.0.37 (#1418) + + * [view commit](https://github.com/yahoo/elide/commit/ffc7a96e30199a401500e4e67c513639d6441d9d) [README] - Fix the standalone README Java sample (#1415) + * [view commit](https://github.com/yahoo/elide/commit/a06aae4650df64df8b168d66588565edc21c0cf6) Bump classgraph from 4.8.86 to 4.8.87 (#1419) + * [view commit](https://github.com/yahoo/elide/commit/388e6a57bdf4cc55d01cc83a42ab824e774e1293) Bump spring.boot.version from 2.3.0.RELEASE to 2.3.1.RELEASE (#1395) + * [view commit](https://github.com/yahoo/elide/commit/fd235f53233e18665657fd669497492b84c82271) Bump postgresql from 42.2.12 to 42.2.14 (#1392) + * [view commit](https://github.com/yahoo/elide/commit/5fd439e5615f8aff2f2568ca2de9b702643f721a) Bump version.jetty from 9.4.29.v20200521 to 9.4.30.v20200611 (#1393) + * [view commit](https://github.com/yahoo/elide/commit/fea32190fcaf46a911ecad3242817c373bdf8a43) Bump build-helper-maven-plugin from 3.1.0 to 3.2.0 (#1391) + * [view commit](https://github.com/yahoo/elide/commit/dae9b09fc3bc3dcc14ee0bb14499bf55e0f3bced) Issue 683 (#1384). In memory filter support for predicates that traverse to-many relationships. + +## 4.6.5 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/bd516473fbeeb47ca05eaf510734f06432c4280a) Disabling Legacy Filter Dialect in Swagger Documentation (#1363) + * [view commit](https://github.com/yahoo/elide/commit/d3cbcc23e9c7719b1a73f627121c6bc5fcb1bf23) Bumping Hibernate Validator Version (#1377) + * [view commit](https://github.com/yahoo/elide/commit/6186da248b7249bcd858a4a433bfdbc50744af60) Bump classgraph from 4.8.78 to 4.8.86 (#1373) + * [view commit](https://github.com/yahoo/elide/commit/e71dd40dfb8adf1b997a345317b016338e2b8153) make sub-ordinate dictionaries inherit injector from multiplexManager's dictionary. (#1380) + * [view commit](https://github.com/yahoo/elide/commit/57dea8e19495e67f83130bb58f99aa2e5c915184) Bump liquibase-core from 3.8.9 to 3.10.0 (#1372) + * [view commit](https://github.com/yahoo/elide/commit/4054908fa66648a0feb2c3001d234556f2fcb463) Bump checkstyle from 8.32 to 8.33 (#1361) + * [view commit](https://github.com/yahoo/elide/commit/27d841954541ebe1a1648fde5a294b003ef1d7e0) Bump junit-jupiter-params from 5.6.0 to 5.6.2 (#1356) + * [view commit](https://github.com/yahoo/elide/commit/2c3c32e7ee729426850f54da9295961192af17d6) Bump jersey-container-servlet-core from 2.30.1 to 2.31 (#1358) + * [view commit](https://github.com/yahoo/elide/commit/7910f05a4ea7cd614798580082f8350843888fde) Bump version.log4j from 2.13.2 to 2.13.3 (#1355) + +## 4.6.4 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/96d8f4c1ad42728e75b7b841c7dad6f58d006f8f) Supported embedded entities in graphql (#1339) + * [view commit](https://github.com/yahoo/elide/commit/9c6c73223c643afa1afe314a205089178ba963ed) Bump version.jersey from 2.30.1 to 2.31 (#1346) + * [view commit](https://github.com/yahoo/elide/commit/86b2189c0149ae09ba027bb438b826ef72ccb477) Bump jersey-container-jetty-servlet from 2.30.1 to 2.31 (#1345) + * [view commit](https://github.com/yahoo/elide/commit/e914202a877c6f7375568d17087647d58bcdf866) Bump spring.boot.version from 2.2.7.RELEASE to 2.3.0.RELEASE (#1344) + * [view commit](https://github.com/yahoo/elide/commit/1cffc61af0ff3e962cf3c5f5b87e35ba54c17291) Bump metrics.version from 4.1.8 to 4.1.9 (#1343) + * [view commit](https://github.com/yahoo/elide/commit/ac5f89655b7f8154853b0d84bddef45438d5fd83) Bump version.jetty from 9.4.28.v20200408 to 9.4.29.v20200521 (#1341) + * [view commit](https://github.com/yahoo/elide/commit/46932c7b1cb7777653c529dcc9d5f3e889390405) Bump guava from 28.2-jre to 29.0-jre (#1340) + +## 4.6.3 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/78c90514ea02fe290907b9318b17c4977cf5b86a) Fixing Invalid CVE Build Error (The wrong package is being flagged) (#1323) + * [view commit](https://github.com/yahoo/elide/commit/a14ccfbb7b558d27799b4e7b1916850519639708) Bump version.jackson from 2.10.3 to 2.11.0 (#1282) + * [view commit](https://github.com/yahoo/elide/commit/227c2dd64ea2deb8828d8fcc81bc342e0e0d4d02) Removed redundant fetch join for filters (#1326) + * [view commit](https://github.com/yahoo/elide/commit/9387412f7a4fa31f3f4818933be0537ee0baa6bd) Add Eclipse import order (#1327) + * [view commit](https://github.com/yahoo/elide/commit/fd9f4ee543913144193ed881472c5939cd0d9969) Bump metrics.version from 4.1.6 to 4.1.8 (#1324) + * [view commit](https://github.com/yahoo/elide/commit/094512b7f853a83cbcb25455deba8830756cebd9) Bump javassist from 3.26.0-GA to 3.27.0-GA (#1318) + * [view commit](https://github.com/yahoo/elide/commit/32b7da5ac3f88e72e438bb573fb542719e7f2752) Bump maven-shade-plugin from 3.2.1 to 3.2.3 (#1315) + * [view commit](https://github.com/yahoo/elide/commit/31292d0c87a02db03929aa48d7d2dd18ca33e893) Bump jedis from 3.2.0 to 3.3.0 (#1317) + * [view commit](https://github.com/yahoo/elide/commit/2c2f181dd524c4648b9360f938073744dcef4451) Bump mysql-connector-java from 8.0.19 to 8.0.20 (#1314) + * [view commit](https://github.com/yahoo/elide/commit/5e86be3f9ee12d6a0c809996002490849c889803) Bump wagon-ssh-external from 3.3.4 to 3.4.0 (#1316) + * [view commit](https://github.com/yahoo/elide/commit/665d587fbf625af11a18224b45d74f0602d7ccc0) Bump tomcat.version from 9.0.34 to 9.0.35 (#1312) + + +## 4.6.2 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/ed6cc703f514d15ddb8eac4f90672075bc334aa6) Adding read only transaction support to JPA store (#1298) + * [view commit](https://github.com/yahoo/elide/commit/04394a077ac3d23d3192cf66c325d4ad85a9d292) GraphQL to-many filter support(#1305) + * [view commit](https://github.com/yahoo/elide/commit/e28ade021782c5fb63cc0531e3a454dba385d5cf) Allow customizing the handling of ReadPermission for filter joins (#1301) + * [view commit](https://github.com/yahoo/elide/commit/21a8332e352ae5626b9d106fbf41007092ecc630) Limited support for new memberof operator (#1291) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/10484b5bc41ee4db664f2c4347f2327be1df8c29) Better errors for missing IDs in Patch Extension Request. (#1278) + * [view commit](https://github.com/yahoo/elide/commit/4f74602c7be16a63c5ef17a045fe649d910d6621) Patch Extension Lifecycle tests (#1293) + * [view commit](https://github.com/yahoo/elide/commit/64987609a9aa5513f363cbedf9db64237e0936f3) Fixing GraphQL 500 errors due to client API errors (#1304) + * [view commit](https://github.com/yahoo/elide/commit/e2b02ad70be80240bdff01b8c0796f0c5a908a4d) Fixing bug where legacy hibernate 5 entity manager store recycles the entity manager (#1308) + * [view commit](https://github.com/yahoo/elide/commit/ba4e3019f50544c9a3a1617add04aa73c4f78688) Update pom.xml + * [view commit](https://github.com/yahoo/elide/commit/c818de6e458fea6214fa10c7cef16e9a317c9214) Fixing Dom4J Owasp Build Failure (by upgrading hibernate 5) (#1309) + * [view commit](https://github.com/yahoo/elide/commit/afe1bce535db091c7e664ce91ca3fbfc14bbdc90) Bump classgraph from 4.8.69 to 4.8.78 (#1302) + * [view commit](https://github.com/yahoo/elide/commit/d8dd0d4567d6720e2c678558c81853e1dd5a65d1) Bump maven-javadoc-plugin from 3.1.1 to 3.2.0 (#1290) + * [view commit](https://github.com/yahoo/elide/commit/81b9197caa48831b32c058e2956a6f005e309f81) Bump jackson-module-jaxb-annotations from 2.10.3 to 2.11.0 (#1288) + * [view commit](https://github.com/yahoo/elide/commit/db5619f782b4ab8d5ceafcd50b615e58cc8185b2) Bump swagger-core from 1.6.0 to 1.6.1 (#1287) + * [view commit](https://github.com/yahoo/elide/commit/fa4dae6a20a820d1d9f08bd957f424964bbd2229) Bump version.log4j from 2.13.1 to 2.13.2 (#1285) + * [view commit](https://github.com/yahoo/elide/commit/1ce3b3cf7197f905ef8164be9229106202d5503a) Bump checkstyle from 8.30 to 8.32 (#1283) + +## 4.6.1 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/d2fefd4b998995469a3cc6fd13f76204a057ce2a) shortcut the test (#1269) + * [view commit](https://github.com/yahoo/elide/commit/e495fc2881d3f7dbfd82b914c23204779ef7534d) Bump classgraph from 4.8.65 to 4.8.69 (#1268) + * [view commit](https://github.com/yahoo/elide/commit/85b13b62646509674fb7dfa41b179b20d3cba49b) Bump snakeyaml from 1.25 to 1.26 (#1264) + * [view commit](https://github.com/yahoo/elide/commit/966db764b738667d8d674ca020224f0bb9936784) Bump version.junit from 5.6.1 to 5.6.2 (#1263) + * [view commit](https://github.com/yahoo/elide/commit/6c13c002a543b606ff2e1bc79249f13ea1d024f1) Bump version.restassured from 4.2.0 to 4.3.0 (#1266) + * [view commit](https://github.com/yahoo/elide/commit/6993e35fbfabff4144b028e53fa24398dd2d2e39) Short Circuit Filter Expression Security If User Checks would pass/fail. (#1275) + * [view commit](https://github.com/yahoo/elide/commit/5e0b345fe1d944dccd5e47e94eb029312b9a6e6f) Bump postgresql from 42.2.11 to 42.2.12 (#1267) + * [view commit](https://github.com/yahoo/elide/commit/6ca8592344f4e7b94ac9f0e4de27944c1f870000) Bump metrics.version from 4.1.5 to 4.1.6 (#1265) + * [view commit](https://github.com/yahoo/elide/commit/48807cd236f2097eb3f1a04ff5348446e3644422) Bump version.jetty from 9.4.27.v20200227 to 9.4.28.v20200408 (#1262) + * [view commit](https://github.com/yahoo/elide/commit/005f02108b33de18b0075e1dc34c6eb2b94eb435) Bump tomcat.version from 9.0.33 to 9.0.34 (#1261) + +## 4.6.0 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/90a4f1e9ab5bae620f31fb511cc9522f55004710) Bump rxjava from 2.2.17 to 2.2.19 (#1253) + * [view commit](https://github.com/yahoo/elide/commit/1d18f9f3925be263ffd16e512e521806ac3ca851) Fixing Spring JSON-API controller to allow JSON-API PATCH content type (#1258) + * [view commit](https://github.com/yahoo/elide/commit/52d7ab1f16a57717c49b73ce2b9725562ac01a94) Bump commons-lang3 from 3.9 to 3.10 (#1257) + * [view commit](https://github.com/yahoo/elide/commit/16f21a04d3be4c3aeefc283586676a7e55cf24e7) Fixes #1244: Incorrect pagination with ToMany filtering (#1254) + * [view commit](https://github.com/yahoo/elide/commit/0c4b3ab3a59ae4b5c4fdaff1dcdec47313233c50) Bump liquibase-core from 3.8.5 to 3.8.9 (#1260) + * [view commit](https://github.com/yahoo/elide/commit/ac313fb67a3e6a21365cb138a38c264c838ac573) Bump version.junit from 5.6.0 to 5.6.1 (#1252) + * [view commit](https://github.com/yahoo/elide/commit/64b4b762bf32c210512d308fedc655d3e2f65e07) Bump resteasy.version from 3.11.0.Final to 3.11.2.Final (#1256) + * [view commit](https://github.com/yahoo/elide/commit/fe2ba95966dbf0a902fae8e4e421764f997b2588) Bump hibernate-search-orm from 5.11.4.Final to 5.11.5.Final (#1247) + * [view commit](https://github.com/yahoo/elide/commit/1106bc5387567e0bec148029b9cd54d22e982f5f) Bump mockito-core from 3.2.4 to 3.3.3 (#1251) + * [view commit](https://github.com/yahoo/elide/commit/3dc10e0022b353b9b0edcbb73b1992bf0848155a) Bump jersey-container-servlet-core from 2.30 to 2.30.1 (#1249) + * [view commit](https://github.com/yahoo/elide/commit/674e2a7b2ae0a6ea5f95360b3ba9cb314a014216) Bump dependency-check-maven from 5.3.0 to 5.3.2 (#1246) + +## 4.5.16 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/912b246451cff7b5833fc173cbc3fbf68cccce2e) Optimize field annotation lookup (#1243) + +## 4.5.15 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/7048968af3a0838da51ccfcaf0d1cff468b2f9f8) Improve performance of initial class scans (#1238) + * [view commit](https://github.com/yahoo/elide/commit/d2d6234daafeea5fb00f622b17565622ef58a267) Allow replacement of custom serde scan (#1242) + * [view commit](https://github.com/yahoo/elide/commit/57bd29c0904d4060d2f8a9ec57c8fa244d01db1f) Patch security fix to make it compatiable with non-ORM data stores (#1240) + * [view commit](https://github.com/yahoo/elide/commit/cfad3ffda19d4dfaf019076663aeff014a9bada6) Fixed N+1 problems for all toOne relationships (#1241) + +## 4.5.14 +**Security** + * [view commit](https://github.com/yahoo/elide/commit/7239e47eca00226550300e5e67cdabbf12145ef0) Enforce ReadPermission for filter expression fields (#1236) + * [view commit](https://github.com/yahoo/elide/commit/d56cf079510bd9f118685d0f347f8373603787b5) Fixes Issue #1211 (XSS mitigation for swagger controller) (#1237) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/194ba9293ad996c0667666ab58857ba552ff3bc9) Bump jersey-container-jetty-servlet from 2.29.1 to 2.30.1 (#1199) + * [view commit](https://github.com/yahoo/elide/commit/f6aef5939102fc7046ee353488ec5b5053a58a94) Update pom.xml + * [view commit](https://github.com/yahoo/elide/commit/a8be84c248ff63be61da51a59c8973405ad8ce08) Bump classgraph from 4.8.60 to 4.8.65 (#1198) + * [view commit](https://github.com/yahoo/elide/commit/2b540be444a567ba5796c41a6f1a10eb4a829fa9) Bump junit-jupiter-params from 5.5.2 to 5.6.0 (#1189) + * [view commit](https://github.com/yahoo/elide/commit/b786396b0feac006b8de0de37d39e16fe5439188) Bump guava from 28.1-jre to 28.2-jre (#1193) + * [view commit](https://github.com/yahoo/elide/commit/0ac62236032f364f4fef74ef42c6241b81cade83) Bump maven-checkstyle-plugin from 3.1.0 to 3.1.1 (#1191) + * [view commit](https://github.com/yahoo/elide/commit/68234cf8fb2fdd9cfaf3a3e90e349728d4d56517) Bump resteasy.version from 3.9.3.Final to 3.11.0.Final (#1223) + * [view commit](https://github.com/yahoo/elide/commit/2cceb052a74197079672801235f134cf9654af3f) Bump postgresql from 42.2.9 to 42.2.11 (#1217) + * [view commit](https://github.com/yahoo/elide/commit/1b4695f1e026a82dfde877fbd0b68d4a9df94396) Bump version.jackson from 2.10.2 to 2.10.3 (#1231) + * [view commit](https://github.com/yahoo/elide/commit/8e2ee9072245f61090512297a4a1f0111ef654fc) Bump jackson-module-jaxb-annotations from 2.10.2 to 2.10.3 (#1234) + * [view commit](https://github.com/yahoo/elide/commit/1c489b622fbeecaa423321331d6b21cc2ae8935e) Bump spring.boot.version from 2.2.4.RELEASE to 2.2.5.RELEASE (#1232) + * [view commit](https://github.com/yahoo/elide/commit/96e98eb520281d2d9ab58e19f8391ff6d9b58100) Bump checkstyle from 8.29 to 8.30 (#1233) + * [view commit](https://github.com/yahoo/elide/commit/fe7ebb06c26f071c8d0da180d262377fc2e0197e) Bump build-helper-maven-plugin from 3.0.0 to 3.1.0 (#1230) + * [view commit](https://github.com/yahoo/elide/commit/16c042515bbfbe56882e07d0cbe7e2d29985c49a) Bump metrics.version from 4.1.2 to 4.1.5 (#1229) + * [view commit](https://github.com/yahoo/elide/commit/af16bdb4e637564173b2f7ef2b9d9554b1370f8d) Bump version.log4j from 2.13.0 to 2.13.1 (#1228) + * [view commit](https://github.com/yahoo/elide/commit/c725b7777e3b2fe4e1e1fa76ef1b7fec6da26518) Bump version.jersey from 2.30 to 2.30.1 (#1227) + * [view commit](https://github.com/yahoo/elide/commit/045c192516bbc463dad71695ef147cc5e7b30e9c) Bump version.jetty from 9.4.26.v20200117 to 9.4.27.v20200227 (#1226) + +## 4.5.13 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/236ed8f9af121a2ebc32dcb9f40c47062c742f6e) Added IsEmpty operation for filter predicate (#1176) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/f7e29a00b1b000acf5eaa3ae07b2094a52aa3c0b) Refactor tests (#1145) + * [view commit](https://github.com/yahoo/elide/commit/601265ebabe4230230a176313c0204780c4f29c1) Hibernate Entity Manager DataStore Harness (#1156) + * [view commit](https://github.com/yahoo/elide/commit/777287ef45e36020afafc4229dcee199c55263d7) Update test dependency on example models (#1155) + * [view commit](https://github.com/yahoo/elide/commit/021a0498bc588b1c3204f14d9f692f3afd4b1c97) Bump spring.boot.version from 2.2.2.RELEASE to 2.2.4.RELEASE (#1158) + * [view commit](https://github.com/yahoo/elide/commit/00b46fe99d7a733f1202db4d83ac76323dbdf92f) Bump version.antlr4 from 4.7.2 to 4.8-1 (#1153) + * [view commit](https://github.com/yahoo/elide/commit/15a6bd4bcc42ffb615e5378b50a198c9d25e7739) Bump version.jackson from 2.10.1 to 2.10.2 (#1148) + * [view commit](https://github.com/yahoo/elide/commit/acee35cf44598b64ef77dacb0ec0a561b71917fc) Revert "Bump version.jackson from 2.10.1 to 2.10.2 (#1148)" (#1159) + * [view commit](https://github.com/yahoo/elide/commit/d23bb54b05358a09545bd42be53b75b445ce1cb1) Bump jersey-container-servlet-core from 2.29.1 to 2.30 (#1152) + * [view commit](https://github.com/yahoo/elide/commit/6622d45a387147c66171c7f3d53707f4655deea1) Bump version.jetty from 9.4.25.v20191220 to 9.4.26.v20200117 (#1147) + * [view commit](https://github.com/yahoo/elide/commit/180b0ac78ce73b28d99046808c9db1d587833bd6) Bump mysql-connector-java from 8.0.18 to 8.0.19 (#1151) + * [view commit](https://github.com/yahoo/elide/commit/b440ed2b9c2372f3121fa0b3b0c2084dbdd27b20) Bump dependency-check-maven from 5.2.4 to 5.3.0 (#1161) + * [view commit](https://github.com/yahoo/elide/commit/bcacaf347315e066c72a6da0ba97754962876a65) JsonApiDocument hashCode and equals were inconsistent (#1163) + * [view commit](https://github.com/yahoo/elide/commit/d6e2093e4144da3b821c700228e5276b13c56ffe) null access suggestions (#1172) + * [view commit](https://github.com/yahoo/elide/commit/04b1fce458fc40438080442a372e4d77a75de5dc) Core tests (#1162) + * [view commit](https://github.com/yahoo/elide/commit/c726f1d6558025e94a8f691f1b1cf66a34494515) Send INFO to Console, TRACE to trace.log (#1173) + * [view commit](https://github.com/yahoo/elide/commit/90df07ded2fe011fd47e11504acd93395c2f4d6e) Consolidate JSON API Content Type constant (#1174) + * [view commit](https://github.com/yahoo/elide/commit/c82214d8ab0316e7b9c19056ca9025bb0f955fbf) Removed Groovy as Dependency (#1175) + * [view commit](https://github.com/yahoo/elide/commit/290adb51d74a943fc05399c0d814186e68623a07) Honor ApiModelProperty annotations for relationships (#1180) + * [view commit](https://github.com/yahoo/elide/commit/349434ee81d31b7221503a7e71433062b7c644fe) bump checkstyle to 8.29 (#1181) + * [view commit](https://github.com/yahoo/elide/commit/ff88afb94f5a94ea0f800c740b07ce960d5943da) Use isEmpty utilitity (#1182) + * [view commit](https://github.com/yahoo/elide/commit/ebddf047a1e10a48a7df798a8000d43a8bfe9b69) Bump lombok from 1.18.10 to 1.18.12 (#1184) + * [view commit](https://github.com/yahoo/elide/commit/1ffbf4fa02ce59bf4c0ba8e458eeb94936108e97) Bump log4j-over-slf4j from 1.7.29 to 1.7.30 (#1177) + * [view commit](https://github.com/yahoo/elide/commit/062c78af07ee4550ac31e9532524a6f7e5daa5bf) Bump version.restassured from 4.1.2 to 4.2.0 (#1169) + * [view commit](https://github.com/yahoo/elide/commit/82bec83a2461501f47121833c397327a148bf33c) Bump version.junit from 5.5.2 to 5.6.0 (#1168) + * [view commit](https://github.com/yahoo/elide/commit/9006bdbbc49c8fe95b4d5819c3e088e12bf5e7a5) Bump rxjava from 2.2.16 to 2.2.17 (#1166) + * [view commit](https://github.com/yahoo/elide/commit/9d3bad32544d4278b711ff812d80342f0b2c8df7) Bump version.jersey from 2.29.1 to 2.30 (#1170) + * [view commit](https://github.com/yahoo/elide/commit/76d8b60d7af862c7657bbdc1ec3077bf09d65916) Bump jackson-module-jaxb-annotations from 2.10.1 to 2.10.2 (#1150) + * [view commit](https://github.com/yahoo/elide/commit/6f47ded351c3d6beeb3a480157dc7436409769e0) Bump version.jackson from 2.10.1 to 2.10.2 (#1165) + +## 4.5.12 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/cd2a159d6958b11d0ee107370fca2dcc5b3175dc) Adds a registry based mechanism for registering Custom GraphQL scalars (#1131) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/fa5da76eeb8ded88d72f015886c9e67159620067) Move repeated @Sql annotations to class level (#1119) + * [view commit](https://github.com/yahoo/elide/commit/5c43ee875977bf16b7dba55f734f7146511adf2e) Fixing OWASP security warning for Tomcat dependency in Spring Web (#1132) + * [view commit](https://github.com/yahoo/elide/commit/4181a10a779f5a6bcc9aa8e7bd248fcb5daddf5a) Bump liquibase-core from 3.8.1 to 3.8.5 (#1135) + * [view commit](https://github.com/yahoo/elide/commit/13b4b11067e8903608de0971ab12097f30af72c0) Bump classgraph from 4.4.12 to 4.8.60 (#1134) + * [view commit](https://github.com/yahoo/elide/commit/d71bf7d1720807c114ae23f3f9094e480d6bd925) Bump maven-source-plugin from 3.2.0 to 3.2.1 (#1125) + * [view commit](https://github.com/yahoo/elide/commit/4772f58556c878af73b8f442741dc212fa5d9a89) Bump version.jetty from 9.4.24.v20191120 to 9.4.25.v20191220 (#1122) + * [view commit](https://github.com/yahoo/elide/commit/3052faece86feab4718ef300dc684d1ece5e76a3) Bump jedis from 3.1.0 to 3.2.0 (#1121) + * [view commit](https://github.com/yahoo/elide/commit/b2c99ac9ce46b51fb4b4c638f0370ea499c612a6) Bump version.log4j from 2.12.1 to 2.13.0 (#1120) + * [view commit](https://github.com/yahoo/elide/commit/404945c46dc8938d8a83a097b2d57a01dca3b29f) Bump dependency-check-maven from 5.2.3 to 5.2.4 (#1081) + * [view commit](https://github.com/yahoo/elide/commit/1eb3d57e5405a918da114a57bcb485566248efad) Fix travis log length (#1142) + +## 4.5.9 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/c5de4be19d1a103abff08fe90abb5a60af39fc5a) Entity dictionary auto-scan for security checks and lifecycle hooks. (#1108) + * [view commit](https://github.com/yahoo/elide/commit/d92fadc66e502ef279f11a3e3cdafdabdc2ecb7b) Added manual override in JpaDataStore to explicitly bind entities (#1114) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/b86539e654a7b092a3f479f74c0b3a11e64f0669) Migrate legacy getting started to elide standalone readme (#1106) + * [view commit](https://github.com/yahoo/elide/commit/2b41c138d6a9388384628b872138cd2cb777fb68) Added license and link to central docs (#1107) + * [view commit](https://github.com/yahoo/elide/commit/4b36a95ef897f6edbc4430a28de423982a9600c3) Update README.md + * [view commit](https://github.com/yahoo/elide/commit/d36520b9afe1fde93caea93b1e5aee7fb432e4c9) Update README.md + * [view commit](https://github.com/yahoo/elide/commit/02a43983fddfaa16ddd7b7d890694867c99ff4de) Bump slf4j-api from 1.7.28 to 1.7.30 (#1115) + * [view commit](https://github.com/yahoo/elide/commit/415ffcef4981bbdc4632b693a2d7a49d340938aa) Bump mockito-core from 3.1.0 to 3.2.4 (#1111) + * [view commit](https://github.com/yahoo/elide/commit/fc3c39fd5a27a4b7543f98f57e3f25069606a60d) Bump rxjava from 2.2.14 to 2.2.16 (#1110) + * [view commit](https://github.com/yahoo/elide/commit/43c2e488c0c184e2db876fa807951c3ba139555c) Bump spring.boot.version from 2.2.1.RELEASE to 2.2.2.RELEASE (#1099) + * [view commit](https://github.com/yahoo/elide/commit/d1366a53b03f27a2e1188c5ebc2a67f318b72c28) Bump metrics.version from 4.1.1 to 4.1.2 (#1104) + * [view commit](https://github.com/yahoo/elide/commit/80cc3ea7a035d8b4d3965bb97442b2b2ee931fff) Bump hibernate-search-orm from 5.11.3.Final to 5.11.4.Final (#1102) + * [view commit](https://github.com/yahoo/elide/commit/fc9de75ab137e37674b8da44cb4de7f4aac14834) Bump postgresql from 42.2.8 to 42.2.9 (#1100) + +## 4.5.8 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/6e05ce93624855a5968eac4f989131e10d518e08) Elide can bind non JPA entities. Class scanning logic is consolidated. (#1088) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/872c43cdf64bce31acd45f54a30d2a1e855b01f2) Bump liquibase-core from 3.8.0 to 3.8.1 (#1082) + * [view commit](https://github.com/yahoo/elide/commit/d3f73941bed705404c4d667072193edfb71338a1) Bump version.jetty from 9.4.22.v20191022 to 9.4.24.v20191120 (#1086) + * [view commit](https://github.com/yahoo/elide/commit/08fd3f53373c41a386b38c5679fa19a427061a50) Bump jackson-module-jaxb-annotations from 2.10.0 to 2.10.1 (#1080) + * [view commit](https://github.com/yahoo/elide/commit/c37a342b65513a69064c42fb80cc6b190bcfef7e) Upgraded dependencies to pass OWASP security scan (#1096) + * [view commit](https://github.com/yahoo/elide/commit/1b9c8aa1acec370f8f1d5f112c4658acef1eddb7) Bump resteasy.version from 3.9.0.Final to 3.9.3.Final (#1091) + * [view commit](https://github.com/yahoo/elide/commit/2d0f033bd1f10803c2b6152407ffdc3d2ac4bba2) Bump swagger-core from 1.5.24 to 1.6.0 (#1079) + * [view commit](https://github.com/yahoo/elide/commit/482e344eabd052287a255bb8f8efddfb2b34e4e7) Bump wagon-ssh-external from 3.3.3 to 3.3.4 (#1078) + * [view commit](https://github.com/yahoo/elide/commit/b4900d5dd42c27ff6b65f18070b0aca7650c9d61) Bump version.jackson from 2.10.0 to 2.10.1 (#1076) + +## 4.5.7 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/1625319a2755cc615716c322057156c45645b707) Added Elide Support For Spring Boot (#1070) + * [view commit](https://github.com/yahoo/elide/commit/cba356a4afed56760d670e41f1b822ef8cd3dc1a) Expose audit logger in standalone (#1075) + 4.1.1 (#1033) + * [view commit](https://github.com/yahoo/elide/commit/6248156e848e84d64b76a99a4c89122f2434430a) Expose opaque user in audits (#1074) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/a1f04eb8bbbdde02379c4ce9e462fa3aacc589ed) Bump mockito-core from 3.0.0 to 3.1.0 (#1036) + * [view commit](https://github.com/yahoo/elide/commit/26ec44156b4578e3c7d8cd9e9d6bd21812707ccd) Added awesome badges for Java & GraphQL (#1045) + * [view commit](https://github.com/yahoo/elide/commit/124ada06d4490fb7973b822b2f14b1f1a21b1ebe) Bump jersey-container-jetty-servlet from 2.29 to 2.29.1 (#1030) + * [view commit](https://github.com/yahoo/elide/commit/4faca446b90fbfc53cf23986e35a80c124b0e537) Bump jetty-continuation from 9.4.19.v20190610 to 9.4.22.v20191022 (#1050) + * [view commit](https://github.com/yahoo/elide/commit/2ce4d51168bfc975b37fe846ef17b997c53f5abd) Bump log4j-over-slf4j from 1.7.26 to 1.7.28 (#1048) + * [view commit](https://github.com/yahoo/elide/commit/748da7726c9dd7db22c6c2c7aedad3137395a489) Bump swagger-core from 1.5.23 to 1.5.24 (#1047) + * [view commit](https://github.com/yahoo/elide/commit/19a9e5ace0c30389f3c5a407607c4d1964a6a459) Update README.md (#1062) + * [view commit](https://github.com/yahoo/elide/commit/600e4423a6c0ee87d4f1ac7b6ae711fc4a66dcef) Updated Elide standalone docs to point to main elide.io getting started docs (#1063) + * [view commit](https://github.com/yahoo/elide/commit/ffea4422f664e4bcc0b4e68cfb4db386b53a65a3) Update README.md (#1065) + * [view commit](https://github.com/yahoo/elide/commit/643454ac2f64eeeb722a0355602a0c9ed3e62f00) Bump rxjava from 2.2.13 to 2.2.14 (#1061) + * [view commit](https://github.com/yahoo/elide/commit/5b2fd8079b7bc0ce13c3729c28831dd17c41cf69) Bump maven-jar-plugin from 3.1.2 to 3.2.0 (#1060) + * [view commit](https://github.com/yahoo/elide/commit/bd6f02cbd307d7dc7ec91778de20aafa9afe1b8d) Bump maven-source-plugin from 3.1.0 to 3.2.0 (#1059) + * [view commit](https://github.com/yahoo/elide/commit/c96f889a3e31596d54932a521fb4614fdc1b9b62) Bump dependency-check-maven from 5.2.1 to 5.2.3 (#1069) + * [view commit](https://github.com/yahoo/elide/commit/7533433829951b4872f892d6b85ea79c8ec4e6be) Bump log4j-over-slf4j from 1.7.28 to 1.7.29 (#1058) + * [view commit](https://github.com/yahoo/elide/commit/2fd764bcdfa6f6ac322c49954294bd5abd7fc9fb) Bump version.jetty from 9.4.21.v20190926 to 9.4.22.v20191022 (#1046) + * [view commit](https://github.com/yahoo/elide/commit/55261b095c1d0f8797d38efc6401479790cf0972) Bump metrics.version from 4.1.0 to + +## 4.5.6 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/01f630782f1bf6d51acd293fab811d3c63998014) Fix elide4.5.5 (#1040) + * There was a compilation change required in FilterExpressionCheck that break SEMVER. + * elide-blog-example had pom issues that prevented artifacts syncing with maven central. + +## 4.5.5 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/1b293735fa2e316fc6c1697efb10e16f80c79ff5) Bump version.jackson from 2.9.9 to 2.9.10 (#981) + * [view commit](https://github.com/yahoo/elide/commit/b3f913ca4464dfd8400257e0f51025aa5c088a31) Update jackson-databind to use version property (#988) + * [view commit](https://github.com/yahoo/elide/commit/c9483378230ead3413cc8e2e37134469637d132f) update types (#946) + * [view commit](https://github.com/yahoo/elide/commit/cb2b6a9837ac0b6cd8a59864847a95dbe42097ce) Bump lombok from 1.18.8 to 1.18.10 (#984) + * [view commit](https://github.com/yahoo/elide/commit/1be081714e8f82ea98ff3b7355d4219277be2e27) Update Apache Commons Beanutils and ANTLR4 CharStreams (#942) + * [view commit](https://github.com/yahoo/elide/commit/93af9b282859952d961374fb1e720c2bfefc3e82) Bump version.jackson from 2.9.10 to 2.10.0 (#989) + * [view commit](https://github.com/yahoo/elide/commit/59ce2b0197cadad4c7d281d07613a30920bd0943) Bump maven-shade-plugin from 3.1.0 to 3.2.1 (#985) + * [view commit](https://github.com/yahoo/elide/commit/fc09deb880ebbc048a72a1b5fb6ea44f37bdc1ad) Bump resteasy.version from 3.1.4.Final to 3.9.0.Final (#979) + * [view commit](https://github.com/yahoo/elide/commit/c65b8c60eac93de9f4565351d8894e00252a1db7) Bump javax.ws.rs-api from 2.0.1 to 2.1.1 (#980) + * [view commit](https://github.com/yahoo/elide/commit/f1aacfff1f2a88c5b87892925457c6a306eb3cf3) Bump javax.transaction-api from 1.2 to 1.3 (#982) + * [view commit](https://github.com/yahoo/elide/commit/0f354bd3408e5c1ab55b427bca36ef935d3ed35c) Bump jedis from 3.0.1 to 3.1.0 (#983) + * [view commit](https://github.com/yahoo/elide/commit/04424843476ccca18f9c3fa7093ad48fefaaf444) ISSUE 864 Fix primitive id field RSQL filter (#866) + * [view commit](https://github.com/yahoo/elide/commit/f6c430238247e4fed1ca401de748aa633980a109) Bump liquibase-core from 3.5.3 to 3.8.0 (#1000) + * [view commit](https://github.com/yahoo/elide/commit/45adbb85b187cd198be5b14ed17aad338a37f4aa) Issue 952 remove testng (#1004) + * [view commit](https://github.com/yahoo/elide/commit/210868f4961be7a5d22d5a15f4226be6da95ad91) Fixed release distribution for elide-example (#1003) + * [view commit](https://github.com/yahoo/elide/commit/c33239dfda53b17c7c2bdd79eba8c9dc7cec5c0b) Bump gson from 2.8.5 to 2.8.6 (#1001) + * [view commit](https://github.com/yahoo/elide/commit/4ce68469660d9a64b613b41314dc35b1b9fb35f7) Bump swagger-core from 1.5.22 to 1.5.23 (#999) + * [view commit](https://github.com/yahoo/elide/commit/6f26ba7ddab303c4f2618c684dfd0a4f33cf7272) Bump jersey-container-servlet-core from 2.29 to 2.29.1 (#998) + * [view commit](https://github.com/yahoo/elide/commit/1a68e3975d590e45942953799249a263b1fce3f5) Bump commons-collections4 from 4.1 to 4.4 (#1013) + * [view commit](https://github.com/yahoo/elide/commit/58f4114bbd90e5b8b6d5e1359fda9f7bf6415f76) Bump hibernate-search-orm from 5.11.2.Final to 5.11.3.Final (#1012) + * [view commit](https://github.com/yahoo/elide/commit/ffabb8784ac718dfbaa4dc34567df57de53ce738) Bump jacoco-maven-plugin from 0.8.4 to 0.8.5 (#1011) + * [view commit](https://github.com/yahoo/elide/commit/65a911c9a3410ca84e9578f9cdad9d8167fe3701) Bump postgresql from 42.2.7 to 42.2.8 (#1009) + * [view commit](https://github.com/yahoo/elide/commit/105cff0782d93e3b7b9704fe3cd6a56ab145700a) Fixes #1007 (#1014) + * [view commit](https://github.com/yahoo/elide/commit/8af100d2035281460a5d1314e4f783225ba92d44) Bump javassist from 3.25.0-GA to 3.26.0-GA (#997) + * [view commit](https://github.com/yahoo/elide/commit/9d3f2f0b568be2445a3dce78f4e0f77b7b001634) Bump rest-assured from 4.1.1 to 4.1.2 (#996) + * [view commit](https://github.com/yahoo/elide/commit/7c4238953a2f24203d5e30322e2c900a1d083ff9) Bump version.jetty from 9.4.20.v20190813 to 9.4.21.v20190926 (#995) + * [view commit](https://github.com/yahoo/elide/commit/14655adaebf31b1e2b980bb5330ce582b3a5c5ee) Bump mysql-connector-java from 8.0.17 to 8.0.18 (#1032) + * [view commit](https://github.com/yahoo/elide/commit/252dbbc38d400967109517e10a941af86b8f4c5b) Avoid checking share permission for redundant add (#1037) + * [view commit](https://github.com/yahoo/elide/commit/ab3136b539af522d93b8d76da7929b4efb57c1fd) Bump rxjava from 2.2.12 to 2.2.13 (#1031) + +## 4.5.4 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/5a10ae4e7897abd1304f9438fe66f5d4ad786f69) Nested update (#978) + +## 4.5.3 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/150601868be35b4b47db274d0dfb7eae025b48c8) Add heroku archetype project (#966) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/887c7cc6e715c848404bdc842dab5a5c9987245c) Bump mockito-core from 2.2.26 to 3.0.0 (#973) + * [view commit](https://github.com/yahoo/elide/commit/074facbf3f2d52878714fe2908ed231496f9f77c) Suppress databind vulnerability warnings until December (#977) + * [view commit](https://github.com/yahoo/elide/commit/7479cf55dad6bfd4c9f123d74518928c2447d780) Bump commons-lang3 from 3.5 to 3.9 (#971) + * [view commit](https://github.com/yahoo/elide/commit/97da49df4f4afca54f55cb20ce4aa0e78f2ce050) Bump rest-assured from 4.0.0 to 4.1.1 (#970) + * [view commit](https://github.com/yahoo/elide/commit/b343831f6e6ceda81fe9b0aa9030d0dc08d1621d) Bump gson from 2.8.0 to 2.8.5 (#967) + * [view commit](https://github.com/yahoo/elide/commit/e2de2a8a7f052ee2564ae8257fd1059892329ca7) Fixed Swagger generation bug where an entity has nothing to sort by (#975) + * [view commit](https://github.com/yahoo/elide/commit/7b7aba5cc1070f1e337348083f923708e75f2be5) Fix GraphQL Nested UPDATE bug. (#974) + * [view commit](https://github.com/yahoo/elide/commit/d88c2569bc9fb99235dbc221f53eec858c836d66) Bump maven-javadoc-plugin from 3.1.0 to 3.1.1 (#972) + * [view commit](https://github.com/yahoo/elide/commit/f4042814b1e621460e86971f62a3be4dae7c7ffd) Bump dependency-check-maven from 5.0.0 to 5.2.1 (#969) + * [view commit](https://github.com/yahoo/elide/commit/1064efa9545e11022eac51630df6a01c7492facb) Bump version.jersey from 2.29 to 2.29.1 (#968) + +## 4.5.2 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/e6a2ffd8abe950fbe05b6429a3e0a8b13deee3ba) Restore provided on jpa (#932) + * [view commit](https://github.com/yahoo/elide/commit/96bac92e39a258901f1336f638c89e9502f5b438) Bump commons-beanutils from 1.9.3 to 1.9.4 + * [view commit](https://github.com/yahoo/elide/commit/083959f218daa94598c599c9cd7090f1aa81e47e) Fix #934: descriptions and example attributes appearing in swagger with empty string value (#935) + * [view commit](https://github.com/yahoo/elide/commit/71b0ce7dd6aadef4b5a949fae922445dade75dea) Refactor IT Tests (ResourceIT and test infrastructure). (#897) + * [view commit](https://github.com/yahoo/elide/commit/4043f9ff55a2bff05bd3953e00c05dd05d8b45b6) Enable test-helper tests (#947) + * [view commit](https://github.com/yahoo/elide/commit/6254f834414fcb73ffa697c1d4eea1e9f8a5567c) Bump version.jetty from 9.4.19.v20190610 to 9.4.20.v20190813 (#922) + * [view commit](https://github.com/yahoo/elide/commit/b0aaf30a83cf218cd01b7f0bc0635c507ea3c581) Update Graphql integration test (#954) + * [view commit](https://github.com/yahoo/elide/commit/00c16b618cdffdc47678bcf21cee95fa5459a636) Bump rxjava from 2.2.0 to 2.2.12 (#936) + * [view commit](https://github.com/yahoo/elide/commit/459c21678fdde2706eed65fb31e6aebaf9a7fdfe) Bump maven-surefire-plugin from 2.22.1 to 2.22.2 (#928) + * [view commit](https://github.com/yahoo/elide/commit/87e260c8cf4a33690c875a0dca50ceca1bcee743) Bump version.jersey from 2.28 to 2.29 (#924) + * [view commit](https://github.com/yahoo/elide/commit/6ec1b7a39270a76bd9c60ad86b8323eca1583551) Bump jersey-container-jetty-servlet from RELEASE to 2.29 (#929) + * [view commit](https://github.com/yahoo/elide/commit/d642f9279f27dfecad436c6cbc59abd9e20d73d1) Bump maven-jar-plugin from 3.0.2 to 3.1.2 (#927) + * [view commit](https://github.com/yahoo/elide/commit/8475b41b7f80849e2bebb1b966ff9c539b6eaf8d) Bump guava from 20.0 to 23.0 (#957) + * [view commit](https://github.com/yahoo/elide/commit/f4dddcb021121625013b5498b79bfd608b5230ff) Bump jersey-container-servlet-core from RELEASE to 2.29 (#962) + * [view commit](https://github.com/yahoo/elide/commit/19f8547d555fe6255844ab09677fbccc548a5624) Bump slf4j-api from 1.7.26 to 1.7.28 (#961) + * [view commit](https://github.com/yahoo/elide/commit/0b026135569856ee84020c249421f172799f1488) Bump build-helper-maven-plugin from 1.12 to 3.0.0 (#960) + * [view commit](https://github.com/yahoo/elide/commit/11000a00c621078b4dc33c52e1aa854188ea9afb) Bump ant from 1.8.2 to 1.10.7 (#959) + * [view commit](https://github.com/yahoo/elide/commit/aae10ce13bba99ddabd12a0475531f34265166a5) Bump junit.version from 5.5.1 to 5.5.2 (#956) + * [view commit](https://github.com/yahoo/elide/commit/1db1a18ab67da8bf18ea5bf54ec47279cee5c7eb) Bump mysql-connector-java from 8.0.16 to 8.0.17 (#955) + +## 4.5.1 +**Features** + * Issue #851. Added new method `enableSwagger()` in `ElideStandaloneSettings` class which allows an easier way for binding swagger docs to the given endpoint. Override this method returning the `Map` object to bind the swagger docs to string endpoint. + * Issue #900. Add `@ApiModelProperty` support to `elide-swagger` that makes it possible to customize `description`, `example`, `readOnly` and `required` attributes of object definitions in resulting generates Swagger document. + +**Fixes** + * [Security] Bump jackson databind from 2.9.9 to 2.9.9.3 + * Issue #913. Fix deserialization for optional top-level meta object (#913) + * Migrated elide-core tests to JUnit 5. + +## 4.5.0 +**Features** + * Issue #815. Added the ability to customize the JPQL generation for a filter operator globally or for a specific entity attribute. + * Alpha release of a new Elide data store (SearchDataStore) that supports full text search on top of an existing data store. + * Issue #871. Add ElideSettings property `encodeErrorResponses`, which when enabled will encode error messages to be safe for HTML. This works for both JSONAPI and GraphQL endpoints, with verbose errors or error object settings enabled/disabled. + * HttpStatusException class now supports the following additional functions: `getErrorResponse(boolean encodeResponse)` and `getVerboseErrorResponse(boolean encodeResponse)` + * Add `GraphQLErrorSerializer` and `ExecutionResultSerializer` which are added to the `ObjectMapper` provided by the ElideSettings. These are used to parse the GraphQL results, instead of using `ExecutionResult#toSpecification`. + +**Fixes** + * Run vulnerability check during build. Updated dependencies to fix CVE-2018-1000632, CVE-2017-15708, CVE-2019-10247 + * Upgrade to Hibernate 5.4.1 + +## 4.4.5 +**Fixes** + * Issue 801 + * Switched to Open JDK 8 + +## 4.4.4 +**Fixes** + * When requesting an ID field whose name is not 'id', an error happens: `No such association id for type xxx`. When the requested field name equals 'id', Elide has been fixed to look for the field with the annotation @Id rather than looking by field name. + * Support RSQL INFIX, POSTFIX, and PREFIX filters on number types: remove '*' before coercing. + +**Features** +* Issue#812 Add support for BigDecimal field in GraphQL. +* Elide standalone now includes a Hikari connection pool & Hibernate batch fetching by default + +## 4.4.3 +**Features** + * When fetching a collection, if there are no filters, sorting, or client specified pagination, the ORM backed data stores will return the proxy object rather than construct a HQL query. This allows the ORM the opportunity to generate SQL to avoid the N+1 problem. + +**Fixes** + * Fixes bug where EntityManager creation for ElideStandalone was not thread safe. + +## 4.4.2 +**Fixes** + * Fix error in lookupEntityClass and add test + * Restore Flush mechanism for Hibernate but allow for customization. + +## 4.4.1 +**Features** + * Switch ElideStandAlone to use JPA DataStore by default + * Enable support for JPA @MapsId annotation on relationships so that client doesn't have + to provide a dummy ID to make entity creation work. + +**Fixes** + * Flush once for patch extension + * ConstraintViolationExceptions are propagated on flush (JPA Transaction) + * Enable support for JPA @MapsId annotation on relationships so that client doesn't have + to provide a dummy ID to make entity creation work. + * Cache all calls to getEntityBinding + +## 4.4.0 +**Features** + * Issue#763 Support for filtering & sorting on computed attributes + * Added [JPA Data Store](https://github.com/yahoo/elide/pull/747) + +**Fixes** + * Throw proper exception on invalid PersistentResource where id=null + * Issue#744 Elide returns wrong date parsing format in 400 error for non-default DateFormats + * Enable RSQL filter dialect by default (in addition to the default filter dialect). + +## 4.3.3 +**Fixes** + * Issue#744 Better error handling for mismatched method in Lifecycle and additional test + * Upgraded puppycrawl.tools (checkstyle) dependency to address CVE-2019-9658 + * Issue#766 Outdated MySQL driver in elide-standalone and examples + +## 4.3.2 +**Fixes** + * Issue#754 + +## 4.3.1 +**Fixes** + * Issue#758 + +**Features** + * New method in EntityDictionary to bind a dependency injection injector function. + +## 4.3.0 +**Fixes** + * Issue#733 + +**Features** + * New elide-example-models package + * New elide-test-helpers package + * Use SecurityContext as default User object + +## 4.2.14 +**Features** + * Added [Codahale InstrumentedFilter](https://metrics.dropwizard.io/3.1.0/manual/servlet/) & corresponding metrics, threads, admin servlets as a setting option for Elide Standalone. + +**Fixes** + * replaced jcabi-mysql-maven-plugin with H2 for testing + * Upgrade Failsafe to 2.22.1 in order to run Hibernate 5 tests. Fixed test failure. + +## 4.2.13 +**Features** + * Add FilterPredicate sub-classes for each operation type + +**Fixes** + * Upgrade jackson databind to 2.9.8 + +## 4.2.12 +**Fixes** + * Issue#730 + * Issue#729 + +## 4.2.11 +**Features** + * Add annotation FilterExpressPath to provide paths for FilterExpressionChecks + +## 4.2.10 +**Fixes** + * Upgrade Jetty Server library to address security alerts + * Issue#703 + * Fix Import Order + +## 4.2.9 +**Fixes** + * Fixed IT tests that were not running. + * Fixed setting private attributes that are inherited. + * Upgrade Jackson databind library to address security alerts + +## 4.2.8 +**Fixes** + * Issue#696 + * Issue#707 + +## 4.2.7 +**Features** + * Add support for asterisk life cycle hooks (hooks that invoke for all fields in a model). + +**Fixes** + * Add support for multiple classloaders when using CoerceUtils ([Issue #689](https://github.com/yahoo/elide/issues/689)) + * Issue#691 + * Issue#644 + +**Features** + * Both JPA Field (new) and Property (4.2.6 and earlier) Access are now supported. + +## 4.2.6 +**Fixes** + * Fix NPE serializing Dates + +## 4.2.5 +**Features** + * ISO8601 and epoch dates can be toggled in Elide Settings + +**Fixes** + * Fix NPE in HibernateEntityManagerStore + * Performance enhancement for DataSerializer and MapConverter + +## 4.2.4 +**Fixes** + * Fixed issues when running and building on Windows OS + +## 4.2.3 +**Features** + * Add `CustomErrorException` and `ErrorObjects` to support custom error objects + * Allow user to configure to return error objects + * Update `ElideStandalone` to allow users to programmatically manipulate the `ServletContextHandler`. + +**Fixes** + * Fixed bug in GraphQL when multiple root documents are present in the same payload. The flush between the documents + did not correctly handle newly created/deleted objects. + * Fixed broken graphql link in README.md + * Fixed elide standalone instructions. + * Fixed hashcode and equals for some test models + +## 4.2.2 +**Fixes** + * Resolve hibernate proxy class for relationship + +## 4.2.1 +**Fixes** + * Fixed #640 + * Log runtime exception as error + +**Features** + * Added "fetch joins" for to-one relationships to improve HQL performance and limit N+1 queries. + +## 4.2.0 +**Features** + * Upgraded hibernate 5 datastore to latest version (5.2.15) + +**Fixes** + * Fixed bug where create-time pre-security hooks were running before any values were set. + +## 4.1.0 +**Fixes** + * Performance enhancements including caching the `Class.getSimpleName`. + * Fixed bug where updatePreSecurity lifecycle hook was being called for object creation. This will no longer be true. This changes the behavior of life cycle hooks (reason for minor version bump). + +**Features** + * Added the ability to register functions (outside entity classes) for lifecycle hook callbacks. + +## 4.0.2 +**Fixes** + * Add support for retrieving values from java `Map` types. These are still modeled as lists of key/value pairs. + * Log GraphQL query bodies. Private information or anything which is not intended to be logged should be passed as a variable as variables values are not logged. + * Handle the `Transaction not closed` error on aborted response. + +## 4.0.1 +**Fixes** + * Change `PersistentResourceFetcher` constructor visibility to public in order to allow this class instantiation outside of the elide-graphql. + +## 4.0.0 + +See: 4.0-beta-5 + +## 4.0-beta-5 +**Fixes** + * Ignore non-entity types if present in the hibernate class metadata in the hibernate stores. This can legitimately occur when tools like envers are used. + +**Features** + * Support GraphQL batch requests. + +## 4.0-beta-4 +**Fixes** + * Ignore provided-- but null-- operation names and variables in GraphQL requests. + * Add additional logging around exception handling. + * Don't swallow generic Exception in Elide. Log it and bubble it up to caller. + * Fix a bug where null filter expressions were possible if no filter was passed in by the user, but permission filters existed. + * Fix support for handling GraphQL variables. + * Support java.util.Date types as new built-in primitive. Expects datetime as epoch millis. + * Fixed issue with supporting variables in mutations. + * Allow for arbitrary in-transaction identifiers for upserts (treated as unique identifier for current tx only). + * Ensure GraphQLEndpoint returns GraphQL spec-compliant response. + +**Features** + * Handle ConstraintViolationException's by extracting the first constraint validation failure. + * Include GraphQL in Elide standalone by default with ability to remove it via dependency management. + * Upgrade to the latest graphql-java version: 6.0. + +## 4.0-beta-3 +**Fixes** + * Updated MIT attribution for portions of MutableGraphQLInputObjectType + * getRelation (single) call filters in-memory to avoid collision on multiple objects being created in the same transaction. + +**Features** + * ChangeSpec is now passed to OnUpdate life cycle hooks (allowing the hooks to see the before & after change to a given field). + +## 4.0-beta-2 +**Fixes** + * Root collection loads now push down security filter predicates. + * Avoid throwing exceptions that must be handled by the containing application, instead throw exceptions that will be handled directly within Elide. + * Restore OnCreatePreSecurity lifecycle hook to occur after fields are populated. + +**Features** + * Added UPDATE operation for GraphQL. + +## 4.0-beta-1 +**Features** + * Elide now supports GraphQL (as well as JSON-API). This feature is in beta. Read the [docs](elide.io) for specifics. Until the artifact moves to stable, + we may change the semantics of the GraphQL API through a minor Elide version release. + * The semantics of `CreationPermission` have changed and can now apply towards fields as well as entities. `UpdatePermission` is never + checked for newly created objects. + * The semantics of `SharePermission` have changed. `SharePermission` can no longer have an expression defined. It either denies permission + or exactly matches `ReadPermission`. + * RSQL queries that compare strings are now case-insensitive. There is not currently a way to make + case sensitive RSQL queries, however the RSQL spec does not provide this either. + Fixes #387 + +**Fixes** + * Updated PreSecurity lifecycle hooks to run prior to inline checks like they should. + +**Misc** + * All deprecated functions from Elide 3.0 have been removed. + * `FilterPredicates` have been restructure to share a common `Path` with other Elide code. + +## 3.2.0 +**Features** + * Updated interface to beta standalone application. Plans to finalize this before Elide 4.0 release. + +**Fixes** + * Rollback relationship handling change. + * Handle ForbiddenAccess only for denied Include, instead of filtering to empty set. + +## 3.1.4 +**Fixes** + * Instead of ForbiddenAccess for denied Include, filter to empty set. + * Generate error when parsing permission expression fails. + +## 3.1.3 + * Add support for @Any relationships through a @MappedInterface + +## 3.1.2 +**Features** + * Add Elide standalone application library + +**Fixes** + * Fix for issue #508 + * Fix for issue #521 + * Fix blog example + * Properly handle proxy beans in HQL Builder + +## 3.1.1 +**Fixes** + * Fix id extraction from multiplex transaction. + +## 3.1.0 +**Fixes** + * Use Entity name when Include is empty. Cleanup Predicate. + +## 3.0.17 +**Features** +Adds support for sorting by relationship (to-one) attributes. +**Misc** +Cleanup equals code style + +## 3.0.16 +**Misc** + * Replaced deprecated Hibernate Criteria with JPQL/HQL. + +## 3.0.15 +**Fixes** + * Use inverse relation type when updating. + +## 3.0.14 +**Fixes** + * Properly handle incorrect relationship field name in Patch request instead of `Entity is null` + * Properly handle invalid filtering input in HQL filtering + * Properly handle NOT in filterexpressionchecks + * Fix parameter order in commit permission check + +## 3.0.13 +**Fixes** + * Fixing regression in deferred permissions for updates + +## 3.0.12 +**Misc** + * Cleanup hibernate stores to not care about multi edit transactions + * Removed dead code from hibernate3 transaction + * Special read permissions handling of newly created objects in Patch Extension + +## 3.0.11 +**Fixes** + * Change `UpdateOnCreate` check to be an `OperationCheck`. + +## 3.0.10 +**Fixes** + * Use IdentityHashMap for ObjectEntityCache + * Miscellaneous cleanup. + +## 3.0.9 +**Fixes** + * Fix exception handler to pass verbose log even with unexpected exceptions. + * Fix life cycle hooks to trigger "general" hooks even when specific field acted upon. + * Build document list for swagger endpoint at the `/` path. + +## 3.0.8 +**Features** + * Add support for FieldSetToNull check. + +## 3.0.7 +**Features** + * Add support for sorting by id values + * Implement functionality for Hibernate5 to support `EntityManager`'s. + +**Fixes** + * Account for inheritance when performing new entity detection during a PATCH Extension request. + * Upgrade examples to behave properly with latest jersey release. + * Rethrow `WebApplicationException` exceptions from error response handler. + +**Misc** + * Always setting HQL 'alias' in FilterPredicate Constructor + +## 3.0.6 +**Misc** +* Cleanup of active permission executor + +## 3.0.5 +**Fixes** +* Fixed caching of security checks (performance optimization) +* Security fix for inline checks being deferred when used in conjunction with commit checks. +* Security fix to not bypass collection filtering for patch extension requests. + +**Features** +* Added UUID type coercion +* Move `InMemoryDataStore` to Elide core. The `InMemoryDataStore` from the `elide-datastore-inmemorydb` package has + been deprecated and will be removed in Elide 4.0 + +## 3.0.4 +**Fixes** +* Do not save deleted objects even if referenced as an inverse from a relationship. + +## 3.0.3 +**Fixes** +* Fix HQL for order by clauses preceded by filters. +* Remove extra `DELETE` endpoint from `JsonApiEndpoint` since it's not compliant across all JAX-RS implementations. +* Add support for matching inherited types while type checking. +* Fix tests to automatically set UTC timestamp. +* Fix README information and various examples. + +## 3.0.2 +**Misc** +* Clean up Elide request handler. + +## 3.0.1 +**Fixes** +* Updated HQL query aliases for page total calculation in hibernate3 and hibernate5 data stores. + +## 3.0.0 +**Features** +* Promoted `DefaultOpaqueUserFunction` to be a top-level class +* Promoted `Elide.Builder` to be a top-level class `ElideSettingsBuilder` +* Revised datastore interface + * Removed hibernate-isms + * Made key-value persistence easier to support +* Revised lifecycle hook model +* Revised audit logger interface +* Removed all deprecated features, e.g. + * SecurityMode + * `any` and `all` permission syntax + * Required use of `ElideSettingsBuilder` + * Removed `PersistenceStore` from Hibernate 5 datastore +* Made `InMemoryDataStore` the reference datastore implementation +* Allow filtering on nested to-one relationships + +**Fixes** +* Close transactions properly +* Updated all dependencies +* Fixed page totals to honor filter & security permissions evaluated in the DB. diff --git a/checkstyle-style.xml b/checkstyle-style.xml index 5881a10553..652f8066cd 100644 --- a/checkstyle-style.xml +++ b/checkstyle-style.xml @@ -18,11 +18,25 @@ + - + + + + + + + + + + + + + + @@ -115,12 +129,9 @@ - - - + + - - @@ -129,20 +140,12 @@ - - - - - - - - - + @@ -183,6 +186,20 @@ + + + + + + + + + + + + + + @@ -194,6 +211,7 @@ + @@ -212,11 +230,6 @@ - - - - - diff --git a/checkstyle-suppressions.xml b/checkstyle-suppressions.xml index 5083c79ee6..5fdfa84ad4 100644 --- a/checkstyle-suppressions.xml +++ b/checkstyle-suppressions.xml @@ -10,7 +10,11 @@ "http://www.puppycrawl.com/dtds/suppressions_1_0.dtd"> - + + + + + diff --git a/elide-annotations/pom.xml b/elide-annotations/pom.xml deleted file mode 100644 index b9561ae5d8..0000000000 --- a/elide-annotations/pom.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - 4.0.0 - elide-annotations - jar - Elide: Annotations - Annotations for Elide - https://github.com/yahoo/elide - - com.yahoo.elide - elide-parent-pom - 2.3.14-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 - - - - - org.projectlombok - lombok - - - - - - - org.apache.maven.plugins - maven-checkstyle-plugin - - - - - diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/CreatePermission.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/CreatePermission.java deleted file mode 100644 index 566f132b02..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/CreatePermission.java +++ /dev/null @@ -1,55 +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 com.yahoo.elide.security.checks.Check; - -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.PACKAGE; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -/** - * Assign custom Create permission checks. - */ -@Target({TYPE, PACKAGE}) -@Retention(RUNTIME) -@Inherited -public @interface CreatePermission { - - /** - * Any one of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] any() default {}; - - /** - * All of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] all() default {}; - - /** - * An expression of checks that will be parsed via ANTLR. For example: - * {@code @CreatePermission(expression="Prefab.Role.All")} or - * {@code @CreatePermission(expression="Prefab.Role.All and Prefab.Role.UpdateOnCreate")} - * - * All of {@linkplain com.yahoo.elide.security.checks.prefab the built-in checks} are name-spaced as - * {@code Prefab.CHECK} without the {@code Check} suffix - * - * @return the expression string to be parsed - */ - String expression() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/DeletePermission.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/DeletePermission.java deleted file mode 100644 index 473b876b39..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/DeletePermission.java +++ /dev/null @@ -1,55 +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 com.yahoo.elide.security.checks.InlineCheck; - -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.PACKAGE; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -/** - * Assign custom Delete permission checks. - */ -@Target({TYPE, PACKAGE}) -@Retention(RUNTIME) -@Inherited -public @interface DeletePermission { - - /** - * Any one of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] any() default {}; - - /** - * All of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] all() default {}; - - /** - * An expression of checks that will be parsed via ANTLR. For example: - * {@code @DeletePermission(expression="Prefab.Role.All")} or - * {@code @DeletePermission(expression="Prefab.Role.All and Prefab.Role.UpdateOnCreate")} - * - * All of {@linkplain com.yahoo.elide.security.checks.prefab the built-in checks} are name-spaced as - * {@code Prefab.CHECK} without the {@code Check} suffix - * - * @return the expression string to be parsed - */ - String expression() default ""; -} 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 deleted file mode 100644 index 59462beb63..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Include.java +++ /dev/null @@ -1,35 +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.annotation; - -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.PACKAGE; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -/** - * Allows access to given entity. - */ -@Target({TYPE, PACKAGE}) -@Retention(RUNTIME) -@Inherited -public @interface Include { - - /** - * (Optional) Whether or not the entity can be accessed at the root URL path (i.e. /company) - * @return the boolean - */ - boolean rootLevel() default false; - - /** - * The type of the JsonApi object. Defaults to the simple name of the entity class. - * @return the string - */ - String type() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCommit.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCommit.java deleted file mode 100644 index 78a1ef6bed..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCommit.java +++ /dev/null @@ -1,23 +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.annotation; - -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -/** - * On Commit trigger annotation. - */ -@Target({METHOD, FIELD}) -@Retention(RUNTIME) -public @interface OnCommit { - - String value() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreate.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreate.java deleted file mode 100644 index 24dd482d7b..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnCreate.java +++ /dev/null @@ -1,22 +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.annotation; - -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -/** - * On Create trigger annotation. - */ -@Target({METHOD, FIELD}) -@Retention(RUNTIME) -public @interface OnCreate { - -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDelete.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDelete.java deleted file mode 100644 index 90ad4826b9..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnDelete.java +++ /dev/null @@ -1,22 +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.annotation; - -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -/** - * On Delete trigger annotation. - */ -@Target({METHOD, FIELD}) -@Retention(RUNTIME) -public @interface OnDelete { - -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdate.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdate.java deleted file mode 100644 index 64e53e3ddb..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/OnUpdate.java +++ /dev/null @@ -1,22 +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.annotation; - -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -/** - * On Update trigger annotation. - */ -@Target({METHOD, FIELD}) -@Retention(RUNTIME) -public @interface OnUpdate { - String value(); -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/ReadPermission.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/ReadPermission.java deleted file mode 100644 index bff1be5890..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/ReadPermission.java +++ /dev/null @@ -1,57 +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 com.yahoo.elide.security.checks.InlineCheck; - -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.ElementType.PACKAGE; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -/** - * Assign custom Read permission checks. - */ -@Target({METHOD, FIELD, TYPE, PACKAGE}) -@Retention(RUNTIME) -@Inherited -public @interface ReadPermission { - - /** - * Any one of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] any() default {}; - - /** - * All of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] all() default {}; - - /** - * An expression of checks that will be parsed via ANTLR. For example: - * {@code @ReadPermission(expression="Prefab.Role.All")} or - * {@code @ReadPermission(expression="Prefab.Role.All and Prefab.Role.UpdateOnCreate")} - * - * All of {@linkplain com.yahoo.elide.security.checks.prefab the built-in checks} are name-spaced as - * {@code Prefab.CHECK} without the {@code Check} suffix - * - * @return the expression string to be parsed - */ - String expression() 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 34426e1b69..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/SharePermission.java +++ /dev/null @@ -1,56 +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 com.yahoo.elide.security.checks.Check; - -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.PACKAGE; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -/** - * A permission that is checked whenever an object is loaded without the context of a lineage and assigned - * to a relationship or collection. - */ -@Target({TYPE, PACKAGE}) -@Retention(RUNTIME) -@Inherited -public @interface SharePermission { - - /** - * Any one of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] any() default {}; - - /** - * All of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] all() default {}; - - /** - * An expression of checks that will be parsed via ANTLR. For example: - * {@code @SharePermission(expression="Prefab.Role.All")} or - * {@code @SharePermission(expression="Prefab.Role.All and Prefab.Role.UpdateOnCreate")} - * - * All of {@linkplain com.yahoo.elide.security.checks.prefab the built-in checks} are name-spaced as - * {@code Prefab.CHECK} without the {@code Check} suffix - * - * @return the expression string to be parsed - */ - String expression() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/UpdatePermission.java b/elide-annotations/src/main/java/com/yahoo/elide/annotation/UpdatePermission.java deleted file mode 100644 index 0c47b6bf1a..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/UpdatePermission.java +++ /dev/null @@ -1,57 +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 com.yahoo.elide.security.checks.Check; - -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - -import static java.lang.annotation.ElementType.FIELD; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.ElementType.PACKAGE; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - -/** - * Assign custom Update permission checks. - */ -@Target({METHOD, FIELD, TYPE, PACKAGE}) -@Retention(RUNTIME) -@Inherited -public @interface UpdatePermission { - - /** - * Any one of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] any() default {}; - - /** - * All of these checks must pass. - * - * @return the array of check classes - * @deprecated as of 2.2, use {@link #expression()} instead. - */ - @Deprecated - Class[] all() default {}; - - /** - * An expression of checks that will be parsed via ANTLR. For example: - * {@code @UpdatePermission(expression="Prefab.Role.All")} or - * {@code @UpdatePermission(expression="Prefab.Role.All and Prefab.Role.UpdateOnCreate")} - * - * All of {@linkplain com.yahoo.elide.security.checks.prefab the built-in checks} are name-spaced as - * {@code Prefab.CHECK} without the {@code Check} suffix - * - * @return the expression string to be parsed - */ - String expression() default ""; -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/ChangeSpec.java b/elide-annotations/src/main/java/com/yahoo/elide/security/ChangeSpec.java deleted file mode 100644 index d02a76c189..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/ChangeSpec.java +++ /dev/null @@ -1,29 +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; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -/** - * ChangeSpec for a particular field. - */ -@AllArgsConstructor -public class ChangeSpec { - @Getter private final PersistentResource resource; - @Getter private final String fieldName; - @Getter private final Object original; - @Getter private final Object modified; - - @Override - public String toString() { - return String.format("ChangeSpec { resource=%s, field=%s, original=%s, modified=%s}", - resource, - fieldName, - original, - modified); - } -} 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 deleted file mode 100644 index 644096d6ec..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/PersistentResource.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; - -import java.util.Optional; - -/** - * The persistent resource interface passed to change specs. - * @param - */ -public interface PersistentResource { - - boolean matchesId(String id); - - Optional getUUID(); - String getId(); - String getType(); - - T getObject(); - Class getResourceClass(); - RequestScope getRequestScope(); -} 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 deleted file mode 100644 index 563fcf15fa..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/RequestScope.java +++ /dev/null @@ -1,18 +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; - -import java.util.Set; - -/** - * The request scope interface passed to checks. - */ -public interface RequestScope { - User getUser(); - Set getNewResources(); - - @Deprecated SecurityMode getSecurityMode(); -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/SecurityMode.java b/elide-annotations/src/main/java/com/yahoo/elide/security/SecurityMode.java deleted file mode 100644 index 58f4c30ba6..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/SecurityMode.java +++ /dev/null @@ -1,22 +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.security; - -/** - * Enable or disable security. - * - * This functionality is deprecated and will soon be unavailable. At time of writing, SecurityMode's - * are almost entirely non-functional within Elide with few exceptions that will remain until this enum - * is removed. - * - * @deprecated Since 2.1, instead use the {@code Elide.Builder} with an appropriate {@code PermissionExecutor} - */ -@Deprecated -public enum SecurityMode { - SECURITY_INACTIVE, - SECURITY_ACTIVE_VERBOSE, - SECURITY_ACTIVE -} 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 deleted file mode 100644 index a1e95414fc..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/User.java +++ /dev/null @@ -1,19 +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; - -import lombok.Getter; - -/** - * Wrapper for opaque user passed in every request. - */ -public class User { - @Getter private final Object opaqueUser; - - public User(Object opaqueUser) { - this.opaqueUser = opaqueUser; - } -} 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 deleted file mode 100644 index 6a18e17cb4..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/Check.java +++ /dev/null @@ -1,42 +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.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(); - } -} 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/CriterionCheck.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/CriterionCheck.java deleted file mode 100644 index 13884c2105..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/CriterionCheck.java +++ /dev/null @@ -1,23 +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.RequestScope; - -/** - * Extends Check to support Hibernate Criteria to limit SQL query responses. - * @param Type of the criterion to return - * @param Type of the record for the Check - */ -public interface CriterionCheck extends Check { - /** - * Get criterion for request scope. - * - * @param requestScope the requestSCope - * @return the criterion - */ - R getCriterion(RequestScope requestScope); -} 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 deleted file mode 100644 index d02472239b..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/OperationCheck.java +++ /dev/null @@ -1,27 +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; - -/** - * Operation check interface. - * @see Check - * - * Operation checks are run in-line (i.e. as soon as objects are first encountered). - * - * NOTE: For non-Read operations, the object passed to this interface is not guaranteed to be complete - * as it will run _BEFORE_ changes are made to the object. - * - * @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(); - } -} 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 deleted file mode 100644 index f44a953060..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/UserCheck.java +++ /dev/null @@ -1,23 +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.security.checks; - -import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.RequestScope; - -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. - */ -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()); - } -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/AddToCollectionCheck.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/AddToCollectionCheck.java deleted file mode 100644 index 6ab8c656ec..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/AddToCollectionCheck.java +++ /dev/null @@ -1,34 +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.prefab; - -import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.RequestScope; -import com.yahoo.elide.security.checks.CommitCheck; - -import java.util.Collection; -import java.util.Optional; - -/** - * A check designed to look at the changeSpec to determine whether a value was added to a Collection - * - * @param type parameter - * @deprecated As of 2.2, use {@link Collections.RemoveOnly} - */ -@Deprecated -public class AddToCollectionCheck extends CommitCheck { - - @Override - public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { - if (changeSpec.isPresent() && changeSpec.get().getModified() instanceof Collection) { - Collection originalCollection = (Collection) changeSpec.get().getOriginal(); - Collection modifiedCollection = (Collection) changeSpec.get().getModified(); - return (modifiedCollection.size() > originalCollection.size()) - && (modifiedCollection.containsAll(originalCollection)); - } - return false; - } -} 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 deleted file mode 100644 index ebc38020b4..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Collections.java +++ /dev/null @@ -1,70 +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.prefab; - -import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.RequestScope; -import com.yahoo.elide.security.checks.CommitCheck; - -import java.util.Collection; -import java.util.Optional; - -/** - * Checks to ensure that collections are only modified in a prescribed manner. - */ -public class Collections { - - /** - * Use changeSpec to enforce that values were exclusively added to the collection. - * - * @param type collection to be validated - */ - public static class AppendOnly extends CommitCheck { - - @Override - public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { - if (!changeSpecIsCollection(changeSpec)) { - return false; - } - - Collection originalCollection = (Collection) changeSpec.get().getOriginal(); - Collection modifiedCollection = (Collection) changeSpec.get().getModified(); - - return collectionIsSuperset(originalCollection, modifiedCollection); - } - } - - /** - * Use changeSpec to enforce that values were exclusively removed from the collection. - * - * @param type parameter - */ - public class RemoveOnly extends CommitCheck { - - @Override - public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { - if (!changeSpecIsCollection(changeSpec)) { - return false; - } - - Collection originalCollection = (Collection) changeSpec.get().getOriginal(); - Collection modifiedCollection = (Collection) changeSpec.get().getModified(); - - return collectionIsSuperset(modifiedCollection, originalCollection); - } - - } - - private static boolean collectionIsSuperset(Collection baseCollection, Collection potentialSuperset) { - return (potentialSuperset.size() >= baseCollection.size()) - && (potentialSuperset.containsAll(baseCollection)); - } - - private static boolean changeSpecIsCollection(Optional changeSpec) { - return changeSpec.isPresent() && changeSpec.get().getModified() instanceof Collection; - } -} 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 deleted file mode 100644 index 193f07eb1a..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Common.java +++ /dev/null @@ -1,37 +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.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 java.util.Optional; - -/** - * 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 CommitCheck { - @Override - public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { - for (PersistentResource resource : requestScope.getNewResources()) { - if (record == resource.getObject()) { - return true; - } - } - return false; - } - } -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/RemoveFromCollectionCheck.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/RemoveFromCollectionCheck.java deleted file mode 100644 index f5168a7e05..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/RemoveFromCollectionCheck.java +++ /dev/null @@ -1,34 +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.prefab; - -import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.RequestScope; -import com.yahoo.elide.security.checks.CommitCheck; - -import java.util.Collection; -import java.util.Optional; - -/** - * A check designed to look at the changeSpec to determine whether a value was removed from a Collection - * - * @param type parameter - * @deprecated Since 2.2, use {@link com.yahoo.elide.security.checks.prefab.Collections.RemoveOnly} instead - */ -@Deprecated -public class RemoveFromCollectionCheck extends CommitCheck { - - @Override - public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { - if (changeSpec.isPresent() && changeSpec.get().getModified() instanceof Collection) { - Collection originalCollection = (Collection) changeSpec.get().getOriginal(); - Collection modifiedCollection = (Collection) changeSpec.get().getModified(); - return (originalCollection.size() > modifiedCollection.size()) - && (originalCollection.containsAll(modifiedCollection)); - } - return false; - } -} 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 deleted file mode 100644 index b9387dd671..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/Role.java +++ /dev/null @@ -1,34 +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.prefab; - -import com.yahoo.elide.security.User; -import com.yahoo.elide.security.checks.UserCheck; - -/** - * Simple checks to always grant or deny. - */ -public class Role { - /** - * Check which always grants. - */ - public static class ALL extends UserCheck { - @Override - public boolean ok(User user) { - return true; - } - } - - /** - * Check which always denies. - */ - public static class NONE extends UserCheck { - @Override - public boolean ok(User user) { - return false; - } - } -} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/UpdateOnCreateCheck.java b/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/UpdateOnCreateCheck.java deleted file mode 100644 index 69a002d779..0000000000 --- a/elide-annotations/src/main/java/com/yahoo/elide/security/checks/prefab/UpdateOnCreateCheck.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.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 java.util.Optional; - -/** - * 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 - * @deprecated As of 2.2, use {@link com.yahoo.elide.security.checks.prefab.Common.UpdateOnCreate} instead. - */ -@Deprecated -public class UpdateOnCreateCheck extends CommitCheck { - @Override - public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { - for (PersistentResource resource : requestScope.getNewResources()) { - if (record == resource.getObject()) { - return true; - } - } - return false; - } -} diff --git a/elide-async/pom.xml b/elide-async/pom.xml new file mode 100644 index 0000000000..0c76b92416 --- /dev/null +++ b/elide-async/pom.xml @@ -0,0 +1,190 @@ + + + + 4.0.0 + elide-async + jar + Elide Async + Elide Async + https://github.com/yahoo/elide + + com.yahoo.elide + elide-parent-pom + 6.1.4-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 + + + + + com.yahoo.elide + elide-graphql + 6.1.4-SNAPSHOT + + + + + javax.persistence + javax.persistence-api + provided + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + com.jayway.jsonpath + json-path + + + + + com.github.opendevl + json2flat + 1.0.3 + + + com.fasterxml.jackson.core + jackson-databind + + + + + + redis.clients + jedis + ${jedis.version} + + + + + com.github.codemonstur + embedded-redis + ${embedded-redis.version} + test + + + + javax.servlet + javax.servlet-api + 4.0.1 + + + org.glassfish.jersey.core + jersey-server + ${version.jersey} + test + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.junit.jupiter + junit-jupiter-params + test + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.junit.platform + junit-platform-launcher + test + + + + + com.h2database + h2 + test + + + + + org.mockito + mockito-core + test + + + org.hibernate.validator + hibernate-validator + 6.1.5.Final + test + + + javax.el + javax.el-api + 3.0.0 + 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/export/formatter/CSVExportFormatter.java b/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/CSVExportFormatter.java new file mode 100644 index 0000000000..3c19433808 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/CSVExportFormatter.java @@ -0,0 +1,126 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.export.formatter; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.opendevl.JFlat; +import lombok.extern.slf4j.Slf4j; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * JSON output format implementation. + */ +@Slf4j +public class CSVExportFormatter implements TableExportFormatter { + private static final String COMMA = ","; + private static final String DOUBLE_QUOTES = "\""; + + private boolean skipCSVHeader = false; + private ObjectMapper mapper; + + public CSVExportFormatter(Elide elide, boolean skipCSVHeader) { + this.skipCSVHeader = skipCSVHeader; + this.mapper = elide.getMapper().getObjectMapper(); + } + + @Override + public String format(PersistentResource resource, Integer recordNumber) { + if (resource == null) { + return null; + } + + StringBuilder str = new StringBuilder(); + + List json2Csv; + + try { + String jsonStr = JSONExportFormatter.resourceToJSON(mapper, resource); + + JFlat flat = new JFlat(jsonStr); + + json2Csv = flat.json2Sheet().headerSeparator("_").getJsonAsSheet(); + + int index = 0; + + for (Object[] obj : json2Csv) { + // convertToCSV is called once for each PersistentResource in the observable. + // json2Csv will always have 2 entries. + // 0th index is the header so we need to skip the header. + if (index++ == 0) { + continue; + } + + String objString = Arrays.toString(obj); + //The arrays.toString returns o/p with [ and ] at the beginning and end. So need to exclude them. + objString = objString.substring(1, objString.length() - 1); + str.append(objString); + } + } catch (Exception e) { + log.error("Exception while converting to CSV: {}", e.getMessage()); + throw new IllegalStateException(e); + } + return str.toString(); + } + + /** + * Generate CSV Header when Observable is Empty. + * @param projection EntityProjection object. + * @return returns Header string which is in CSV format. + */ + private String generateCSVHeader(EntityProjection projection) { + if (projection.getAttributes() == null) { + return ""; + } + + return projection.getAttributes().stream() + .map(this::toHeader) + .collect(Collectors.joining(COMMA)); + } + + @Override + public String preFormat(EntityProjection projection, TableExport query) { + if (projection == null || skipCSVHeader) { + return null; + } + + return generateCSVHeader(projection); + } + + @Override + public String postFormat(EntityProjection projection, TableExport query) { + return null; + } + + private String toHeader(Attribute attribute) { + if (attribute.getArguments() == null || attribute.getArguments().size() == 0) { + return quote(attribute.getName()); + } + + StringBuilder header = new StringBuilder(); + header.append(attribute.getName()); + header.append("("); + + header.append(attribute.getArguments().stream() + .map((arg) -> arg.getName() + "=" + arg.getValue()) + .collect(Collectors.joining(" "))); + + header.append(")"); + + return quote(header.toString()); + } + + private String quote(String toQuote) { + return "\"" + toQuote + "\""; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/JSONExportFormatter.java b/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/JSONExportFormatter.java new file mode 100644 index 0000000000..d94cecd080 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/JSONExportFormatter.java @@ -0,0 +1,94 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.export.formatter; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.jsonapi.models.Resource; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.lang3.StringUtils; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * JSON output format implementation. + */ +@Slf4j +public class JSONExportFormatter implements TableExportFormatter { + private static final String COMMA = ","; + private ObjectMapper mapper; + + public JSONExportFormatter(Elide elide) { + this.mapper = elide.getMapper().getObjectMapper(); + } + + @Override + public String format(PersistentResource resource, Integer recordNumber) { + if (resource == null) { + return null; + } + + StringBuilder str = new StringBuilder(); + if (recordNumber > 1) { + // Add "," to separate individual json rows within the array + str.append(COMMA); + } + + str.append(resourceToJSON(mapper, resource)); + return str.toString(); + } + + public static String resourceToJSON(ObjectMapper mapper, PersistentResource resource) { + if (resource == null || resource.getObject() == null) { + return null; + } + + StringBuilder str = new StringBuilder(); + try { + Resource jsonResource = resource.toResource(getRelationships(resource), getAttributes(resource)); + + str.append(mapper.writeValueAsString(jsonResource.getAttributes())); + } catch (JsonProcessingException e) { + log.error("Exception when converting to JSON {}", e.getMessage()); + throw new IllegalStateException(e); + } + return str.toString(); + } + + private static Map getAttributes(PersistentResource resource) { + final Map attributes = new LinkedHashMap<>(); + final Set attrFields = resource.getRequestScope().getEntityProjection().getAttributes(); + + for (Attribute field : attrFields) { + String alias = field.getAlias(); + String fieldName = StringUtils.isNotEmpty(alias) ? alias : field.getName(); + attributes.put(fieldName, resource.getAttribute(field)); + } + return attributes; + } + + private static Map getRelationships(PersistentResource resource) { + return Collections.emptyMap(); + } + + @Override + public String preFormat(EntityProjection projection, TableExport query) { + return "["; + } + + @Override + public String postFormat(EntityProjection projection, TableExport query) { + return "]"; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/TableExportFormatter.java b/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/TableExportFormatter.java new file mode 100644 index 0000000000..955041ba72 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/TableExportFormatter.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.export.formatter; + +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.request.EntityProjection; + +/** + * Interface which is used to format PersistentResource to output format. + */ +public interface TableExportFormatter { + + /** + * Format PersistentResource. + * @param resource PersistentResource to format + * @param recordNumber Record Number being processed. + * @return output string + */ + public String format(PersistentResource resource, Integer recordNumber); + + /** + * Pre Format Action. + * Example: Generate Header, Metadata etc. + * @param projection Entity projection. + * @param query TableExport type object. + * @return output string + */ + public String preFormat(EntityProjection projection, TableExport query); + + /** + * Post Format Action. + * Example: Generate Metadata, Stats etc. + * @param projection Entity projection. + * @param query TableExport type object. + * @return output string + */ + public String postFormat(EntityProjection projection, TableExport query); +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/export/validator/NoRelationshipsProjectionValidator.java b/elide-async/src/main/java/com/yahoo/elide/async/export/validator/NoRelationshipsProjectionValidator.java new file mode 100644 index 0000000000..215b54a726 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/export/validator/NoRelationshipsProjectionValidator.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.export.validator; + +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.request.EntityProjection; + +import java.util.Collection; + +/** + * Validates none of the projections have relationships. + */ +public class NoRelationshipsProjectionValidator implements Validator { + + @Override + public void validateProjection(Collection projections) { + for (EntityProjection projection : projections) { + if (!projection.getRelationships().isEmpty()) { + throw new BadRequestException( + "Export is not supported for Query that requires traversing Relationships."); + } + } + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/export/validator/SingleRootProjectionValidator.java b/elide-async/src/main/java/com/yahoo/elide/async/export/validator/SingleRootProjectionValidator.java new file mode 100644 index 0000000000..3704284a3f --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/export/validator/SingleRootProjectionValidator.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.export.validator; + +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.request.EntityProjection; + +import java.util.Collection; + +/** + * Validates each projection in collection have one projection only. + */ +public class SingleRootProjectionValidator implements Validator { + + @Override + public void validateProjection(Collection projections) { + if (projections.size() != 1) { + throw new BadRequestException("Export is only supported for single Query with one root projection."); + } + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/export/validator/Validator.java b/elide-async/src/main/java/com/yahoo/elide/async/export/validator/Validator.java new file mode 100644 index 0000000000..638ebba215 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/export/validator/Validator.java @@ -0,0 +1,22 @@ +/* + * 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.export.validator; + +import com.yahoo.elide.core.request.EntityProjection; + +import java.util.Collection; + +/** + * Utility interface used to validate Entity Projections. + */ +public interface Validator { + + /** + * Validates the EntityProjection. + * @param projections Collection of EntityProjections to validate. + */ + public void validateProjection(Collection projections); +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncAPIHook.java b/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncAPIHook.java new file mode 100644 index 0000000000..5b7334ffd0 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncAPIHook.java @@ -0,0 +1,119 @@ +/* + * 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 static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.POSTCOMMIT; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PREFLUSH; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRESECURITY; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.core.exceptions.InvalidValueException; +import com.yahoo.elide.core.lifecycle.LifeCycleHook; +import com.yahoo.elide.core.security.RequestScope; +import lombok.Data; + +import java.security.Principal; +import java.util.concurrent.Callable; + +/** + * AsyncAPI Base Hook methods. + * @param Type of AsyncAPI. + */ +@Data +public abstract class AsyncAPIHook implements LifeCycleHook { + private final AsyncExecutorService asyncExecutorService; + private final Integer maxAsyncAfterSeconds; + + public AsyncAPIHook(AsyncExecutorService asyncExecutorService, Integer maxAsyncAfterSeconds) { + this.asyncExecutorService = asyncExecutorService; + this.maxAsyncAfterSeconds = maxAsyncAfterSeconds; + } + + /** + * Validate the Query Options before executing. + * @param query AsyncAPI type object. + * @param requestScope RequestScope object. + * @throws InvalidValueException InvalidValueException + */ + protected void validateOptions(AsyncAPI query, RequestScope requestScope) { + if (query.getAsyncAfterSeconds() > maxAsyncAfterSeconds) { + throw new InvalidValueException("Invalid Async After Seconds"); + } + } + + /** + * Execute the Hook. + * @param query AsyncAPI type object. + * @param requestScope RequestScope object. + * @param queryWorker Thread to execute. + * @throws InvalidOperationException InvalidOperationException + */ + protected void executeHook(LifeCycleHookBinding.Operation operation, LifeCycleHookBinding.TransactionPhase phase, + AsyncAPI query, RequestScope requestScope, Callable queryWorker) { + if (operation.equals(CREATE)) { + if (phase.equals(PREFLUSH)) { + validateOptions(query, requestScope); + executeAsync(query, queryWorker); + return; + } + if (phase.equals(POSTCOMMIT)) { + completeAsync(query, requestScope); + return; + } + if (phase.equals(PRESECURITY)) { + updatePrincipalName(query, requestScope); + return; + } + } + + throw new InvalidOperationException("Invalid LifeCycle Hook Invocation"); + } + + /** + * Call the completeQuery process in AsyncExecutorService. + * @param query AsyncAPI object to complete. + * @param requestScope RequestScope object. + */ + protected void completeAsync(AsyncAPI query, RequestScope requestScope) { + asyncExecutorService.completeQuery(query, requestScope.getUser(), requestScope.getApiVersion()); + } + + /** + * Call the executeQuery process on AsyncExecutorService. + * @param query AsyncAPI object to complete. + * @param callable CallableThread instance. + */ + protected void executeAsync(AsyncAPI query, Callable callable) { + if (query.getStatus() == QueryStatus.QUEUED && query.getResult() == null) { + asyncExecutorService.executeQuery(query, callable); + } + } + + /** + * Update Principal Name. + * @param query AsyncAPI object to complete. + * @param requestScope RequestScope object. + */ + protected void updatePrincipalName(AsyncAPI query, RequestScope requestScope) { + Principal principal = requestScope.getUser().getPrincipal(); + if (principal != null) { + query.setPrincipalName(principal.getName()); + } + } + + /** + * Get Callable operation to submit. + * @param query AsyncAPI object to complete. + * @param requestScope RequestScope object. + * @return Callable initialized. + */ + public abstract Callable getOperation(AsyncAPI query, RequestScope requestScope); +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncQueryHook.java b/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncQueryHook.java new file mode 100644 index 0000000000..c54b7269d5 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncQueryHook.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.async.hooks; + +import com.yahoo.elide.annotation.LifeCycleHookBinding.Operation; +import com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.async.operation.GraphQLAsyncQueryOperation; +import com.yahoo.elide.async.operation.JSONAPIAsyncQueryOperation; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.graphql.QueryRunner; + +import java.util.Optional; +import java.util.concurrent.Callable; + +/** + * LifeCycle Hook for execution of AsyncQuery. + */ +public class AsyncQueryHook extends AsyncAPIHook { + + public AsyncQueryHook (AsyncExecutorService asyncExecutorService, Integer maxAsyncAfterSeconds) { + super(asyncExecutorService, maxAsyncAfterSeconds); + } + + @Override + public void execute(Operation operation, TransactionPhase phase, AsyncQuery query, RequestScope requestScope, + Optional changes) { + Callable callable = getOperation(query, requestScope); + executeHook(operation, phase, query, requestScope, callable); + } + + @Override + public void validateOptions(AsyncAPI query, RequestScope requestScope) { + super.validateOptions(query, requestScope); + + if (query.getQueryType().equals(QueryType.GRAPHQL_V1_0)) { + QueryRunner runner = getAsyncExecutorService().getRunners().get(requestScope.getApiVersion()); + if (runner == null) { + throw new InvalidOperationException("Invalid API Version"); + } + } + } + + @Override + public Callable getOperation(AsyncAPI query, RequestScope requestScope) { + Callable operation = null; + if (query.getQueryType().equals(QueryType.JSONAPI_V1_0)) { + operation = new JSONAPIAsyncQueryOperation(getAsyncExecutorService(), query, + (com.yahoo.elide.core.RequestScope) requestScope); + } else { + operation = new GraphQLAsyncQueryOperation(getAsyncExecutorService(), query, + (com.yahoo.elide.core.RequestScope) requestScope); + } + return operation; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/hooks/TableExportHook.java b/elide-async/src/main/java/com/yahoo/elide/async/hooks/TableExportHook.java new file mode 100644 index 0000000000..c87eeb873f --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/hooks/TableExportHook.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.async.hooks; + +import com.yahoo.elide.annotation.LifeCycleHookBinding.Operation; +import com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase; +import com.yahoo.elide.async.export.formatter.TableExportFormatter; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.async.models.ResultType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.operation.GraphQLTableExportOperation; +import com.yahoo.elide.async.operation.JSONAPITableExportOperation; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; + +/** + * LifeCycle Hook for execution of TableExpoer. + */ +public class TableExportHook extends AsyncAPIHook { + Map supportedFormatters; + ResultStorageEngine engine; + + public TableExportHook (AsyncExecutorService asyncExecutorService, Integer maxAsyncAfterSeconds, + Map supportedFormatters, ResultStorageEngine engine) { + super(asyncExecutorService, maxAsyncAfterSeconds); + this.supportedFormatters = supportedFormatters; + this.engine = engine; + } + + @Override + public void execute(Operation operation, TransactionPhase phase, TableExport export, RequestScope requestScope, + Optional changes) { + Callable callable = getOperation(export, requestScope); + executeHook(operation, phase, export, requestScope, callable); + } + + @Override + public void validateOptions(AsyncAPI export, RequestScope requestScope) { + super.validateOptions(export, requestScope); + } + + @Override + public Callable getOperation(AsyncAPI export, RequestScope requestScope) { + Callable operation = null; + TableExport exportObj = (TableExport) export; + ResultType resultType = exportObj.getResultType(); + QueryType queryType = exportObj.getQueryType(); + com.yahoo.elide.core.RequestScope scope = (com.yahoo.elide.core.RequestScope) requestScope; + + TableExportFormatter formatter = supportedFormatters.get(resultType); + + if (formatter == null) { + throw new InvalidOperationException("Formatter unavailable for " + resultType); + } + + if (queryType.equals(QueryType.GRAPHQL_V1_0)) { + operation = new GraphQLTableExportOperation(formatter, getAsyncExecutorService(), export, scope, engine); + } else if (queryType.equals(QueryType.JSONAPI_V1_0)) { + operation = new JSONAPITableExportOperation(formatter, getAsyncExecutorService(), export, scope, engine); + } else { + throw new InvalidOperationException(queryType + "is not supported"); + } + return operation; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncAPI.java b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncAPI.java new file mode 100644 index 0000000000..e8d80a416b --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncAPI.java @@ -0,0 +1,91 @@ +/* + * 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.ComputedAttribute; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.UpdatePermission; +import lombok.Data; + +import java.util.Date; +import java.util.UUID; +import javax.persistence.Column; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.Id; +import javax.persistence.MappedSuperclass; +import javax.persistence.PreUpdate; +import javax.persistence.Transient; +import javax.validation.constraints.Pattern; + +/** + * Base Model Class for Async Query. + */ +@MappedSuperclass +@Data +public abstract class AsyncAPI 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 = UUID.randomUUID().toString(); //Provided by client or generated if missing on create. + + protected String query; //JSON-API PATH or GraphQL payload. + + protected QueryType queryType; //GRAPHQL, JSONAPI + + @Exclude + @Column(columnDefinition = "varchar(36)") + protected String requestId = UUID.randomUUID().toString(); + + @CreatePermission(expression = "Prefab.Role.None") + private String principalName; + + @UpdatePermission(expression = "(Principal is Admin OR Principal is Owner) AND value is Cancelled") + @CreatePermission(expression = "value is Queued") + @Enumerated(EnumType.STRING) + private QueryStatus status = QueryStatus.QUEUED; + + @CreatePermission(expression = "Prefab.Role.None") + @UpdatePermission(expression = "Prefab.Role.None") + private Date createdOn = new Date(); + + @CreatePermission(expression = "Prefab.Role.None") + @UpdatePermission(expression = "Prefab.Role.None") + private Date updatedOn = new Date(); + + @Transient + @ComputedAttribute + private Integer asyncAfterSeconds = 10; + + /** + * Set Async API Result. + * @param result Base Result Object to persist. + */ + public abstract void setResult(AsyncAPIResult result); + + /** + * Get Async API Result. + * @return AsyncAPIResult object. + */ + public abstract AsyncAPIResult getResult(); + + @PreUpdate + public void preUpdate() { + this.updatedOn = new Date(); + } + + @Override + public int hashCode() { + return id.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof AsyncAPI && this.getClass() == obj.getClass() && id.equals(((AsyncAPI) obj).id); + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncAPIResult.java b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncAPIResult.java new file mode 100644 index 0000000000..8eb0103fd7 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncAPIResult.java @@ -0,0 +1,25 @@ +/* + * 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 lombok.Data; + +import java.util.Date; +import javax.persistence.MappedSuperclass; + +/** + * Base Model for Async Query Result. + */ +@MappedSuperclass +@Data +public abstract class AsyncAPIResult { + + private Integer recordCount; + + private Integer httpStatus; // HTTP Status + + private Date completedOn = new Date(); +} 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..d28332ad59 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncQuery.java @@ -0,0 +1,38 @@ +/* + * 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.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Embedded; +import javax.persistence.Entity; + +/** + * Model for Async Query. + * AsyncQueryHook is binded manually during the elide startup, + * after asyncexecutorservice is initialized. + */ +@Entity +@Include(name = "asyncQuery") +@ReadPermission(expression = "Principal is Owner OR Principal is Admin") +@UpdatePermission(expression = "Prefab.Role.None") +@DeletePermission(expression = "Prefab.Role.None") +@Data +@EqualsAndHashCode(callSuper = true) +public class AsyncQuery extends AsyncAPI { + @Embedded + private AsyncQueryResult result; + + @Override + public void setResult(AsyncAPIResult result) { + this.result = (AsyncQueryResult) result; + } +} 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..3be5b3f382 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/AsyncQueryResult.java @@ -0,0 +1,25 @@ +/* + * 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 lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Embeddable; +import javax.persistence.Lob; + +/** + * Model for Async Query Result. + */ +@Embeddable +@Data +@EqualsAndHashCode(callSuper = true) +public class AsyncQueryResult extends AsyncAPIResult { + private Integer contentLength; + + @Lob + private String responseBody; //URL or Response body +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/FileExtensionType.java b/elide-async/src/main/java/com/yahoo/elide/async/models/FileExtensionType.java new file mode 100644 index 0000000000..409d2e7fa6 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/FileExtensionType.java @@ -0,0 +1,26 @@ +/* + * Copyright 2022, 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 file extension types. + */ +public enum FileExtensionType { + JSON(".json"), + CSV(".csv"), + NONE(""); + + private final String extension; + + FileExtensionType(String extension) { + this.extension = extension; + } + + public String getExtension() { + return extension; + } +} 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..35029f45aa --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/QueryStatus.java @@ -0,0 +1,19 @@ +/* + * 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, + CANCEL_COMPLETE +} 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/ResultType.java b/elide-async/src/main/java/com/yahoo/elide/async/models/ResultType.java new file mode 100644 index 0000000000..35e7e79904 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/ResultType.java @@ -0,0 +1,24 @@ +/* + * 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 result types. + */ +public enum ResultType { + JSON(FileExtensionType.JSON), + CSV(FileExtensionType.CSV); + + private final FileExtensionType fileExtensionType; + + ResultType(FileExtensionType fileExtensionType) { + this.fileExtensionType = fileExtensionType; + } + + public FileExtensionType getFileExtensionType() { + return fileExtensionType; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/TableExport.java b/elide-async/src/main/java/com/yahoo/elide/async/models/TableExport.java new file mode 100644 index 0000000000..b031e81329 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/TableExport.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021, 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.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Embedded; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.validation.constraints.NotNull; + +/** + * Model for Table Export. + * TableExportHook is binded manually during the elide startup, + * after asyncexecutorservice is initialized. + */ +@Entity +@Include(name = "tableExport") +@ReadPermission(expression = "Principal is Owner OR Principal is Admin") +@UpdatePermission(expression = "Prefab.Role.None") +@DeletePermission(expression = "Prefab.Role.None") +@Data +@EqualsAndHashCode(callSuper = true) +public class TableExport extends AsyncAPI { + + @Enumerated(EnumType.STRING) + @NotNull + private ResultType resultType; //CSV, JSON + + @Embedded + private TableExportResult result; + + @Override + public void setResult(AsyncAPIResult result) { + this.result = (TableExportResult) result; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/TableExportResult.java b/elide-async/src/main/java/com/yahoo/elide/async/models/TableExportResult.java new file mode 100644 index 0000000000..0a668a66e2 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/TableExportResult.java @@ -0,0 +1,26 @@ +/* + * Copyright 2021, 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 lombok.Data; +import lombok.EqualsAndHashCode; + +import java.net.URL; +import javax.persistence.Embeddable; +import javax.persistence.Lob; + +/** + * Model for Table Export Result. + */ +@Embeddable +@Data +@EqualsAndHashCode(callSuper = true) +public class TableExportResult extends AsyncAPIResult { + private URL url; + + @Lob + private String message; +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/security/AsyncAPIInlineChecks.java b/elide-async/src/main/java/com/yahoo/elide/async/models/security/AsyncAPIInlineChecks.java new file mode 100644 index 0000000000..8b6ae7311d --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/security/AsyncAPIInlineChecks.java @@ -0,0 +1,97 @@ +/* + * 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.AsyncAPI; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.filter.Operator; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.security.checks.FilterExpressionCheck; +import com.yahoo.elide.core.security.checks.OperationCheck; +import com.yahoo.elide.core.security.checks.UserCheck; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; + +import java.security.Principal; +import java.util.Collections; +import java.util.Optional; + +/** + * Operation Checks on the Async API and Result objects. + */ +public class AsyncAPIInlineChecks { + private static final String PRINCIPAL_NAME = "principalName"; + + static private FilterPredicate getPredicateOfPrincipalName(String principalName, Type entityClass) { + Path.PathElement path = new Path.PathElement(entityClass, ClassType.STRING_TYPE, PRINCIPAL_NAME); + return new FilterPredicate(path, Operator.IN, Collections.singletonList(principalName)); + } + + static private FilterPredicate getPredicateOfPrincipalNameNull(Type entityClass) { + Path.PathElement path = new Path.PathElement(entityClass, ClassType.STRING_TYPE, PRINCIPAL_NAME); + return new FilterPredicate(path, Operator.ISNULL, Collections.emptyList()); + } + + /** + * Filter for principalName == Owner. + * @param type parameter + */ + @SecurityCheck(AsyncAPIOwner.PRINCIPAL_IS_OWNER) + static public class AsyncAPIOwner extends FilterExpressionCheck { + public static final String PRINCIPAL_IS_OWNER = "Principal is Owner"; + /** + * query principalName == owner. + */ + @Override + public FilterExpression getFilterExpression(Type entityClass, RequestScope requestScope) { + Principal principal = requestScope.getUser().getPrincipal(); + if (principal == null || principal.getName() == null) { + return getPredicateOfPrincipalNameNull(entityClass); + } + return getPredicateOfPrincipalName(principal.getName(), entityClass); + } + } + + @SecurityCheck(AsyncAPIAdmin.PRINCIPAL_IS_ADMIN) + public static class AsyncAPIAdmin extends UserCheck { + + public static final String PRINCIPAL_IS_ADMIN = "Principal is Admin"; + + @Override + public boolean ok(User user) { + if (user != null && user.getPrincipal() != null) { + return user.isInRole("admin"); + } + return false; + } + } + + @SecurityCheck(AsyncAPIStatusValue.VALUE_IS_CANCELLED) + public static class AsyncAPIStatusValue extends OperationCheck { + + public static final String VALUE_IS_CANCELLED = "value is Cancelled"; + + @Override + public boolean ok(AsyncAPI object, RequestScope requestScope, Optional changeSpec) { + return changeSpec.get().getModified().toString().equals(QueryStatus.CANCELLED.name()); + } + } + + @SecurityCheck(AsyncAPIStatusQueuedValue.VALUE_IS_QUEUED) + public static class AsyncAPIStatusQueuedValue extends OperationCheck { + public static final String VALUE_IS_QUEUED = "value is Queued"; + @Override + public boolean ok(AsyncAPI object, RequestScope requestScope, Optional changeSpec) { + return changeSpec.get().getModified().toString().equals(QueryStatus.QUEUED.name()); + } + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncAPIUpdateOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncAPIUpdateOperation.java new file mode 100644 index 0000000000..dda12591ee --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncAPIUpdateOperation.java @@ -0,0 +1,51 @@ +/* + * 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.operation; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.Future; + +/** + * Runnable Operation for updating AsyncQueryResult. + */ +@Slf4j +@Data +@AllArgsConstructor +public class AsyncAPIUpdateOperation implements Runnable { + + private Elide elide; + private Future task; + private AsyncAPI queryObj; + private AsyncAPIDAO asyncAPIDao; + + /** + * This is the main method which updates the Async API request. + */ + @Override + public void run() { + try { + AsyncAPIResult queryResultObj = task.get(); + // add queryResult object to query object + asyncAPIDao.updateAsyncAPIResult(queryResultObj, queryObj.getId(), queryObj.getClass()); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("InterruptedException: {}", e.toString()); + asyncAPIDao.updateStatus(queryObj.getId(), QueryStatus.FAILURE, queryObj.getClass()); + } catch (Exception e) { + log.error("Exception: {}", e.toString()); + asyncAPIDao.updateStatus(queryObj.getId(), QueryStatus.FAILURE, queryObj.getClass()); + } + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncQueryOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncQueryOperation.java new file mode 100644 index 0000000000..14289aafff --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncQueryOperation.java @@ -0,0 +1,110 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.AsyncQueryResult; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.core.RequestScope; +import com.jayway.jsonpath.JsonPath; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.net.URISyntaxException; +import java.util.Date; +import java.util.List; +import java.util.concurrent.Callable; + +/** + * AsyncQuery Execute Operation Interface. + */ +@Slf4j +public abstract class AsyncQueryOperation implements Callable { + @Getter private AsyncExecutorService service; + private AsyncQuery queryObj; + private RequestScope scope; + + public AsyncQueryOperation(AsyncExecutorService service, AsyncAPI queryObj, RequestScope scope) { + this.service = service; + this.queryObj = (AsyncQuery) queryObj; + this.scope = scope; + } + + @Override + public AsyncAPIResult call() throws URISyntaxException { + ElideResponse response = null; + log.debug("AsyncQuery Object from request: {}", queryObj); + response = execute(queryObj, scope); + nullResponseCheck(response); + + AsyncQueryResult queryResult = new AsyncQueryResult(); + queryResult.setHttpStatus(response.getResponseCode()); + queryResult.setCompletedOn(new Date()); + queryResult.setResponseBody(response.getBody()); + queryResult.setContentLength(response.getBody().length()); + if (response.getResponseCode() == 200) { + queryResult.setRecordCount(calculateRecordCount(queryObj, response)); + } + return queryResult; + } + + /** + * Calculate Record Count in the response. + * @param queryObj AsyncAPI type object. + * @param response ElideResponse object. + * @return Integer record count + */ + public abstract Integer calculateRecordCount(AsyncQuery queryObj, ElideResponse response); + + /** + * Check if Elide Response is NULL. + * @param response ElideResponse object. + * @throws IllegalStateException IllegalStateException Exception. + */ + public void nullResponseCheck(ElideResponse response) { + if (response == null) { + throw new IllegalStateException("No Response for request returned"); + } + } + + /** + * Execute the Async Query Request. + * @param queryObj AsyncAPI type object. + * @param scope RequestScope. + * @return response ElideResponse object. + * @throws URISyntaxException URISyntaxException Exception. + */ + public abstract ElideResponse execute(AsyncAPI queryObj, RequestScope scope) throws URISyntaxException; + + /** + * Safe method of extracting 'foo.bar.length()' expressions from com.jayway.jsonpath. This protects + * against breaking API changes between 2.4 and beyond. + * @param json The json body to extract from. + * @param path The json path expression that represents an array. + * @return The size of the array. + */ + public static Integer safeJsonPathLength(String json, String path) { + Object result = JsonPath.read(json, path); + + if (Integer.class.isAssignableFrom(result.getClass())) { + return (Integer) result; + } + + if (List.class.isAssignableFrom(result.getClass())) { + List resultList = ((List) result); + + result = resultList.isEmpty() ? 0 : resultList.get(0); + if (Integer.class.isAssignableFrom(result.getClass())) { + return (Integer) result; + } + } + + throw new IllegalStateException("Incompatible version of JSONPath"); + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperation.java new file mode 100644 index 0000000000..0bd64a3e07 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperation.java @@ -0,0 +1,55 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.graphql.QueryRunner; +import lombok.extern.slf4j.Slf4j; + +import java.net.URISyntaxException; +import java.util.UUID; + +/** + * GrapqhQL implementation of AsyncQueryOperation for executing the query provided in AsyncQuery. + */ +@Slf4j +public class GraphQLAsyncQueryOperation extends AsyncQueryOperation { + + public GraphQLAsyncQueryOperation(AsyncExecutorService service, AsyncAPI queryObj, RequestScope scope) { + super(service, queryObj, scope); + } + + @Override + public ElideResponse execute(AsyncAPI queryObj, RequestScope scope) throws URISyntaxException { + User user = scope.getUser(); + String apiVersion = scope.getApiVersion(); + QueryRunner runner = getService().getRunners().get(apiVersion); + if (runner == null) { + throw new InvalidOperationException("Invalid API Version"); + } + UUID requestUUID = UUID.fromString(queryObj.getRequestId()); + //TODO - we need to add the baseUrlEndpoint to the queryObject. + ElideResponse response = runner.run("", queryObj.getQuery(), user, requestUUID, scope.getRequestHeaders()); + log.debug("GRAPHQL_V1_0 getResponseCode: {}, GRAPHQL_V1_0 getBody: {}", + response.getResponseCode(), response.getBody()); + return response; + } + + @Override + public Integer calculateRecordCount(AsyncQuery queryObj, ElideResponse response) { + Integer count = 0; + if (response.getResponseCode() == 200) { + count = safeJsonPathLength(response.getBody(), "$..edges.length()"); + } + return count; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLTableExportOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLTableExportOperation.java new file mode 100644 index 0000000000..5045f9cc50 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLTableExportOperation.java @@ -0,0 +1,77 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.export.formatter.TableExportFormatter; +import com.yahoo.elide.async.export.validator.NoRelationshipsProjectionValidator; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.graphql.GraphQLRequestScope; +import com.yahoo.elide.graphql.QueryRunner; +import com.yahoo.elide.graphql.parser.GraphQLEntityProjectionMaker; +import com.yahoo.elide.graphql.parser.GraphQLProjectionInfo; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * TableExport Execute Operation Interface. + */ +@Slf4j +public class GraphQLTableExportOperation extends TableExportOperation { + + public GraphQLTableExportOperation(TableExportFormatter formatter, AsyncExecutorService service, + AsyncAPI export, RequestScope scope, ResultStorageEngine engine) { + super(formatter, service, export, scope, engine, + Arrays.asList(new NoRelationshipsProjectionValidator())); + } + + @Override + public RequestScope getRequestScope(TableExport export, RequestScope scope, DataStoreTransaction tx, + Map> additionalRequestHeaders) { + UUID requestId = UUID.fromString(export.getRequestId()); + User user = scope.getUser(); + String apiVersion = scope.getApiVersion(); + return new GraphQLRequestScope("", tx, user, apiVersion, getService().getElide().getElideSettings(), + null, requestId, additionalRequestHeaders); + } + + @Override + public Collection getProjections(TableExport export, RequestScope scope) { + GraphQLProjectionInfo projectionInfo; + try { + String graphQLDocument = export.getQuery(); + Elide elide = getService().getElide(); + ObjectMapper mapper = elide.getMapper().getObjectMapper(); + + JsonNode node = QueryRunner.getTopLevelNode(mapper, graphQLDocument); + Map variables = QueryRunner.extractVariables(mapper, node); + String queryString = QueryRunner.extractQuery(node); + + projectionInfo = new GraphQLEntityProjectionMaker(elide.getElideSettings(), variables, + scope.getApiVersion()).make(queryString); + + } catch (IOException e) { + throw new IllegalStateException(e); + } + + return projectionInfo.getProjections().values(); + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPIAsyncQueryOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPIAsyncQueryOperation.java new file mode 100644 index 0000000000..d46fbb5d5a --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPIAsyncQueryOperation.java @@ -0,0 +1,85 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.security.User; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URIBuilder; +import lombok.extern.slf4j.Slf4j; + +import java.net.URISyntaxException; +import java.util.UUID; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; + +/** + * JSON API implementation of AsyncQueryOperation for executing the query provided in AsyncQuery. + */ +@Slf4j +public class JSONAPIAsyncQueryOperation extends AsyncQueryOperation { + + public JSONAPIAsyncQueryOperation(AsyncExecutorService service, AsyncAPI queryObj, RequestScope scope) { + super(service, queryObj, scope); + } + + @Override + public ElideResponse execute(AsyncAPI queryObj, RequestScope scope) + throws URISyntaxException { + Elide elide = getService().getElide(); + User user = scope.getUser(); + String apiVersion = scope.getApiVersion(); + UUID requestUUID = UUID.fromString(queryObj.getRequestId()); + URIBuilder uri = new URIBuilder(queryObj.getQuery()); + MultivaluedMap queryParams = getQueryParams(uri); + log.debug("Extracted QueryParams from AsyncQuery Object: {}", queryParams); + + //TODO - we need to add the baseUrlEndpoint to the queryObject. + ElideResponse response = elide.get("", getPath(uri), queryParams, scope.getRequestHeaders(), user, apiVersion, + requestUUID); + log.debug("JSONAPI_V1_0 getResponseCode: {}, JSONAPI_V1_0 getBody: {}", + response.getResponseCode(), response.getBody()); + return response; + } + + /** + * 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 uri URIBuilder instance + * @return MultivaluedMap with query parameters + */ + public static MultivaluedMap getQueryParams(URIBuilder uri) { + 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 uri URIBuilder instance + * @return Path extracted from URI + */ + public static String getPath(URIBuilder uri) { + return uri.getPath(); + } + + @Override + public Integer calculateRecordCount(AsyncQuery queryObj, ElideResponse response) { + Integer count = null; + if (response.getResponseCode() == 200) { + count = safeJsonPathLength(response.getBody(), "$.data.length()"); + } + return count; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPITableExportOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPITableExportOperation.java new file mode 100644 index 0000000000..b8e20f94c9 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPITableExportOperation.java @@ -0,0 +1,92 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.export.formatter.TableExportFormatter; +import com.yahoo.elide.async.export.validator.NoRelationshipsProjectionValidator; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.jsonapi.EntityProjectionMaker; +import org.apache.http.client.utils.URIBuilder; +import lombok.extern.slf4j.Slf4j; + +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import javax.ws.rs.core.MultivaluedMap; + +/** + * JSONAPI TableExport Execute Operation. + */ +@Slf4j +public class JSONAPITableExportOperation extends TableExportOperation { + + public JSONAPITableExportOperation(TableExportFormatter formatter, AsyncExecutorService service, + AsyncAPI export, RequestScope scope, ResultStorageEngine engine) { + super(formatter, service, export, scope, engine, + Arrays.asList(new NoRelationshipsProjectionValidator())); + } + + @Override + public RequestScope getRequestScope(TableExport export, RequestScope scope, DataStoreTransaction tx, + Map> additionalRequestHeaders) { + UUID requestId = UUID.fromString(export.getRequestId()); + User user = scope.getUser(); + String apiVersion = scope.getApiVersion(); + URIBuilder uri; + try { + uri = new URIBuilder(export.getQuery()); + } catch (URISyntaxException e) { + throw new BadRequestException(e.getMessage()); + } + + MultivaluedMap queryParams = JSONAPIAsyncQueryOperation.getQueryParams(uri); + + // Call with additionalHeader alone + if (scope.getRequestHeaders().isEmpty()) { + return new RequestScope("", JSONAPIAsyncQueryOperation.getPath(uri), apiVersion, null, tx, user, + queryParams, additionalRequestHeaders, requestId, getService().getElide().getElideSettings()); + } + + // Combine additionalRequestHeaders and existing scope's request headers + Map> finalRequestHeaders = new HashMap>(); + scope.getRequestHeaders().forEach((entry, value) -> finalRequestHeaders.put(entry, value)); + + //additionalRequestHeaders will override any headers in scope.getRequestHeaders() + additionalRequestHeaders.forEach((entry, value) -> finalRequestHeaders.put(entry, value)); + + return new RequestScope("", JSONAPIAsyncQueryOperation.getPath(uri), apiVersion, null, tx, user, queryParams, + scope.getRequestHeaders(), requestId, getService().getElide().getElideSettings()); + } + + @Override + public Collection getProjections(TableExport export, RequestScope scope) { + EntityProjection projection = null; + try { + URIBuilder uri = new URIBuilder(export.getQuery()); + Elide elide = getService().getElide(); + projection = new EntityProjectionMaker(elide.getElideSettings().getDictionary(), + scope).parsePath(JSONAPIAsyncQueryOperation.getPath(uri)); + + } catch (URISyntaxException e) { + throw new BadRequestException(e.getMessage()); + } + return Collections.singletonList(projection); + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/TableExportOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/TableExportOperation.java new file mode 100644 index 0000000000..68ea59e7a3 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/TableExportOperation.java @@ -0,0 +1,197 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.export.formatter.TableExportFormatter; +import com.yahoo.elide.async.export.validator.SingleRootProjectionValidator; +import com.yahoo.elide.async.export.validator.Validator; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.FileExtensionType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.models.TableExportResult; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.request.EntityProjection; +import io.reactivex.Observable; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Callable; + +/** + * TableExport Execute Operation Interface. + */ +@Slf4j +public abstract class TableExportOperation implements Callable { + private TableExportFormatter formatter; + @Getter private AsyncExecutorService service; + private Integer recordNumber = 0; + private TableExport exportObj; + private RequestScope scope; + private ResultStorageEngine engine; + private List validators = new ArrayList<>(Arrays.asList(new SingleRootProjectionValidator())); + + public TableExportOperation(TableExportFormatter formatter, AsyncExecutorService service, + AsyncAPI exportObj, RequestScope scope, ResultStorageEngine engine, List validators) { + this.formatter = formatter; + this.service = service; + this.exportObj = (TableExport) exportObj; + this.scope = scope; + this.engine = engine; + this.validators.addAll(validators); + } + + @Override + public AsyncAPIResult call() { + log.debug("TableExport Object from request: {}", exportObj); + Elide elide = service.getElide(); + TableExportResult exportResult = new TableExportResult(); + UUID requestId = UUID.fromString(exportObj.getRequestId()); + try (DataStoreTransaction tx = elide.getDataStore().beginTransaction()) { + // Do Not Cache Export Results + Map> requestHeaders = new HashMap>(); + requestHeaders.put("bypasscache", new ArrayList(Arrays.asList("true"))); + + RequestScope requestScope = getRequestScope(exportObj, scope, tx, requestHeaders); + Collection projections = getProjections(exportObj, requestScope); + validateProjections(projections); + EntityProjection projection = projections.iterator().next(); + + Observable observableResults = Observable.empty(); + + elide.getTransactionRegistry().addRunningTransaction(requestId, tx); + + //TODO - we need to add the baseUrlEndpoint to the queryObject. + //TODO - Can we have projectionInfo as null? + requestScope.setEntityProjection(projection); + + if (projection != null) { + projection.setPagination(null); + observableResults = PersistentResource.loadRecords(projection, Collections.emptyList(), requestScope); + } + + Observable results = Observable.empty(); + String preResult = formatter.preFormat(projection, exportObj); + results = observableResults.map(resource -> { + this.recordNumber++; + return formatter.format(resource, recordNumber); + }); + String postResult = formatter.postFormat(projection, exportObj); + + // Stitch together Pre-Formatted, Formatted, Post-Formatted results of Formatter in single observable. + Observable interimResults = concatStringWithObservable(preResult, results, true); + Observable finalResults = concatStringWithObservable(postResult, interimResults, false); + + TableExportResult result = storeResults(exportObj, engine, finalResults); + + if (result != null && result.getMessage() != null) { + throw new IllegalStateException(result.getMessage()); + } + + exportResult.setUrl(new URL(generateDownloadURL(exportObj, scope))); + exportResult.setRecordCount(recordNumber); + + tx.flush(requestScope); + elide.getAuditLogger().commit(); + tx.commit(requestScope); + } catch (BadRequestException e) { + exportResult.setMessage(e.getMessage()); + } catch (MalformedURLException e) { + exportResult.setMessage("Download url generation failure."); + } catch (IOException e) { + log.error("IOException during TableExport", e); + exportResult.setMessage(e.getMessage()); + } catch (Exception e) { + exportResult.setMessage(e.getMessage()); + } finally { + // Follows same flow as GraphQL. The query may result in failure but request was successfully processed. + exportResult.setHttpStatus(200); + exportResult.setCompletedOn(new Date()); + elide.getTransactionRegistry().removeRunningTransaction(requestId); + elide.getAuditLogger().clear(); + } + return exportResult; + } + + private Observable concatStringWithObservable(String toConcat, Observable observable, + boolean stringFirst) { + if (toConcat == null) { + return observable; + } + + return stringFirst ? Observable.just(toConcat).concatWith(observable) + : observable.concatWith(Observable.just(toConcat)); + } + + /** + * Initializes a new RequestScope for the export operation with the submitted query. + * @param exportObj TableExport type object. + * @param scope RequestScope from the original submission. + * @param tx DataStoreTransaction. + * @param additionalRequestHeaders Additional Request Headers. + * @return RequestScope Type Object + */ + public abstract RequestScope getRequestScope(TableExport exportObj, RequestScope scope, DataStoreTransaction tx, + Map> additionalRequestHeaders); + + /** + * Generate Download URL. + * @param exportObj TableExport type object. + * @param scope RequestScope. + * @return URL generated. + */ + public String generateDownloadURL(TableExport exportObj, RequestScope scope) { + String downloadPath = scope.getElideSettings().getExportApiPath(); + String baseURL = scope.getBaseUrlEndPoint(); + String extension = this.engine.isExtensionEnabled() + ? exportObj.getResultType().getFileExtensionType().getExtension() + : FileExtensionType.NONE.getExtension(); + return baseURL + downloadPath + "/" + exportObj.getId() + extension; + } + + /** + * Store Export Results using the ResultStorageEngine. + * @param exportObj TableExport type object. + * @param resultStorageEngine ResultStorageEngine instance. + * @param result Observable of String Results to store. + * @return TableExportResult object. + */ + protected TableExportResult storeResults(TableExport exportObj, ResultStorageEngine resultStorageEngine, + Observable result) { + return resultStorageEngine.storeResults(exportObj, result); + } + + private void validateProjections(Collection projections) { + validators.forEach(validator -> validator.validateProjection(projections)); + } + + /** + * Generate Entity Projection from the query. + * @param exportObj TableExport type object. + * @param requestScope requestScope object. + * @return Collection of EntityProjection object. + */ + public abstract Collection getProjections(TableExport exportObj, RequestScope requestScope); + +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/resources/ExportApiEndpoint.java b/elide-async/src/main/java/com/yahoo/elide/async/resources/ExportApiEndpoint.java new file mode 100644 index 0000000000..8711f6fc00 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/resources/ExportApiEndpoint.java @@ -0,0 +1,116 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.resources; + +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.exceptions.HttpStatus; +import io.reactivex.Observable; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.container.AsyncResponse; +import javax.ws.rs.container.Suspended; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.ResponseBuilder; +import javax.ws.rs.core.StreamingOutput; + +/** + * Default endpoint/servlet for using Elide and Async Export. + */ +@Slf4j +@Singleton +@Path("/") +public class ExportApiEndpoint { + protected final ExportApiProperties exportApiProperties; + protected final ResultStorageEngine resultStorageEngine; + + @Data + @AllArgsConstructor + public static class ExportApiProperties { + private ExecutorService executor; + private Integer maxDownloadTimeSeconds; + } + + @Inject + public ExportApiEndpoint( + @Named("resultStorageEngine") ResultStorageEngine resultStorageEngine, + @Named("exportApiProperties") ExportApiProperties exportApiProperties) { + this.resultStorageEngine = resultStorageEngine; + this.exportApiProperties = exportApiProperties; + } + + /** + * Read handler. + * + * @param asyncQueryId asyncQueryId to download results + * @param asyncResponse AsyncResponse object + */ + @GET + @Path("/{asyncQueryId}") + public void get(@PathParam("asyncQueryId") String asyncQueryId, @Context HttpServletResponse httpServletResponse, + @Suspended final AsyncResponse asyncResponse) { + asyncResponse.setTimeout(exportApiProperties.getMaxDownloadTimeSeconds(), TimeUnit.SECONDS); + asyncResponse.setTimeoutHandler(async -> { + ResponseBuilder resp = Response.status(Response.Status.REQUEST_TIMEOUT).entity("Timed out."); + async.resume(resp.build()); + }); + + exportApiProperties.getExecutor().submit(() -> { + Observable observableResults = resultStorageEngine.getResultsByID(asyncQueryId); + + StreamingOutput streamingOutput = outputStream -> + observableResults + .subscribe( + resultString -> outputStream.write(resultString.concat(System.lineSeparator()).getBytes()), + error -> { + String message = error.getMessage(); + try { + log.debug(message); + if (message != null && message.equals(ResultStorageEngine.RETRIEVE_ERROR)) { + httpServletResponse.sendError(HttpStatus.SC_NOT_FOUND, asyncQueryId + " Not Found"); + } else { + httpServletResponse.sendError(HttpStatus.SC_INTERNAL_SERVER_ERROR); + } + } catch (IllegalStateException e) { + // If stream was flushed, Attachment download has already started. + // response.sendError causes java.lang.IllegalStateException: + // Cannot call sendError() after the response has been committed. + // This will return 200 status. + // Add error message in the attachment as a way to signal errors. + outputStream.write( + "Error Occured...." + .concat(System.lineSeparator()) + .getBytes() + ); + log.debug(e.getMessage()); + } finally { + outputStream.flush(); + outputStream.close(); + } + }, + () -> { + outputStream.flush(); + outputStream.close(); + } + ); + + asyncResponse.resume(Response.ok(streamingOutput, MediaType.APPLICATION_OCTET_STREAM) + .header("Content-Disposition", "attachment; filename=" + asyncQueryId).build()); + }); + } +} 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..9930f304a4 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncCleanerService.java @@ -0,0 +1,92 @@ +/* + * 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.service.dao.AsyncAPIDAO; +import com.yahoo.elide.async.service.thread.AsyncAPICancelRunnable; +import com.yahoo.elide.async.service.thread.AsyncAPICleanerRunnable; +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 queries status + * after host/app crash or restart. + */ +@Slf4j +public class AsyncCleanerService { + + private final int defaultCleanupDelayMinutes = 120; + private final int maxInitialDelayMinutes = 100; + private static AsyncCleanerService asyncCleanerService = null; + + @Inject + private AsyncCleanerService(Elide elide, Integer maxRunTimeSeconds, Integer queryCleanupDays, + Integer cancelDelaySeconds, AsyncAPIDAO asyncQueryDao) { + + //If query is still running for twice than maxRunTime, then interrupt did not work due to host/app crash. + int queryRunTimeThresholdMinutes = (int) TimeUnit.SECONDS.toMinutes(maxRunTimeSeconds * 2 + 30L); + + // Setting up query cleaner that marks long running query as TIMEDOUT. + ScheduledExecutorService cleaner = Executors.newSingleThreadScheduledExecutor(); + AsyncAPICleanerRunnable cleanUpTask = new AsyncAPICleanerRunnable( + queryRunTimeThresholdMinutes, elide, queryCleanupDays, asyncQueryDao, new DateUtil()); + + // 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, maxInitialDelayMinutes).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); + + //Setting up query cancel service that cancels long running queries based on status or runtime + ScheduledExecutorService cancellation = Executors.newSingleThreadScheduledExecutor(); + + AsyncAPICancelRunnable cancelTask = new AsyncAPICancelRunnable(maxRunTimeSeconds, + elide, asyncQueryDao); + + cancellation.scheduleWithFixedDelay(cancelTask, 0, cancelDelaySeconds, TimeUnit.SECONDS); + } + + /** + * Initialize the singleton AsyncCleanerService object. + * If already initialized earlier, no new object is created. + * @param elide Elide Instance + * @param maxRunTimeSeconds max run times in seconds + * @param queryCleanupDays Async Query Clean up days + * @param cancelDelaySeconds Async Query Transaction cancel delay + * @param asyncQueryDao DAO Object + */ + public static void init(Elide elide, Integer maxRunTimeSeconds, Integer queryCleanupDays, + Integer cancelDelaySeconds, AsyncAPIDAO asyncQueryDao) { + if (asyncCleanerService == null) { + asyncCleanerService = new AsyncCleanerService(elide, maxRunTimeSeconds, queryCleanupDays, + cancelDelaySeconds, 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..18227c579f --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/AsyncExecutorService.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 com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.async.operation.AsyncAPIUpdateOperation; +import com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.graphql.QueryRunner; +import lombok.Data; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +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 { + + public static final int DEFAULT_THREAD_POOL_SIZE = 6; + + private Elide elide; + private Map runners; + private ExecutorService executor; + private ExecutorService updater; + private AsyncAPIDAO asyncAPIDao; + private ThreadLocal asyncResultFutureThreadLocal = new ThreadLocal<>(); + + /** + * A Future with Synchronous Execution Complete Flag. + */ + @Data + private static class AsyncAPIResultFuture { + private Future asyncFuture; + private boolean synchronousTimeout = false; + } + + @Inject + public AsyncExecutorService(Elide elide, ExecutorService executor, ExecutorService updater, + AsyncAPIDAO asyncAPIDao) { + this.elide = elide; + runners = new HashMap<>(); + + for (String apiVersion : elide.getElideSettings().getDictionary().getApiVersions()) { + runners.put(apiVersion, new QueryRunner(elide, apiVersion)); + } + + this.executor = executor; + this.updater = updater; + this.asyncAPIDao = asyncAPIDao; + } + + /** + * Execute Query asynchronously. + * @param queryObj Query Object + * @param callable A Callabale implementation to execute in background. + */ + public void executeQuery(AsyncAPI queryObj, Callable callable) { + AsyncAPIResultFuture resultFuture = new AsyncAPIResultFuture(); + try { + Future asyncExecuteFuture = executor.submit(callable); + resultFuture.setAsyncFuture(asyncExecuteFuture); + queryObj.setStatus(QueryStatus.PROCESSING); + AsyncAPIResult queryResultObj = asyncExecuteFuture.get(queryObj.getAsyncAfterSeconds(), TimeUnit.SECONDS); + queryObj.setResult(queryResultObj); + queryObj.setStatus(QueryStatus.COMPLETE); + queryObj.setUpdatedOn(new Date()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("InterruptedException: {}", e.toString()); + queryObj.setStatus(QueryStatus.FAILURE); + } catch (ExecutionException e) { + log.error("ExecutionException: {}", e.toString()); + queryObj.setStatus(QueryStatus.FAILURE); + } catch (TimeoutException e) { + log.error("TimeoutException: {}", e.toString()); + resultFuture.setSynchronousTimeout(true); + } catch (Exception e) { + log.error("Exception: {}", e.toString()); + queryObj.setStatus(QueryStatus.FAILURE); + } finally { + asyncResultFutureThreadLocal.set(resultFuture); + } + + } + /** + * Complete Query asynchronously. + * @param query AsyncQuery + * @param user User + * @param apiVersion API Version + */ + public void completeQuery(AsyncAPI query, User user, String apiVersion) { + AsyncAPIResultFuture asyncAPIResultFuture = asyncResultFutureThreadLocal.get(); + if (asyncAPIResultFuture.isSynchronousTimeout()) { + log.debug("Task has not completed"); + updater.execute(new AsyncAPIUpdateOperation(elide, asyncAPIResultFuture.getAsyncFuture(), query, + asyncAPIDao)); + asyncResultFutureThreadLocal.remove(); + } else { + log.debug("Task has completed"); + } + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/DateUtil.java b/elide-async/src/main/java/com/yahoo/elide/async/service/DateUtil.java new file mode 100644 index 0000000000..e98a05508f --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/DateUtil.java @@ -0,0 +1,34 @@ +/* + * 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 lombok.extern.slf4j.Slf4j; + +import java.util.Calendar; +import java.util.Date; + +/** + * Utility class which implements a static method calculateFilterDate. + */ +@Slf4j +public class DateUtil { + + /** + * Calculated 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 filter date + */ + public Date calculateFilterDate(int calendarUnit, int amount) { + Calendar cal = Calendar.getInstance(); + cal.setTime(new Date()); + cal.add(calendarUnit, -(amount)); + Date filterDate = cal.getTime(); + log.debug("FilterDateFormatted = {}", filterDate); + return filterDate; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/dao/AsyncAPIDAO.java b/elide-async/src/main/java/com/yahoo/elide/async/service/dao/AsyncAPIDAO.java new file mode 100644 index 0000000000..eb3ba03bbc --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/dao/AsyncAPIDAO.java @@ -0,0 +1,66 @@ +/* + * 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.dao; + +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.core.filter.expression.FilterExpression; + +/** + * Utility interface which uses the elide datastore to modify and create AsyncAPI and AsyncAPIResult Objects. + */ +public interface AsyncAPIDAO { + + /** + * This method updates the QueryStatus for AsyncAPI for given QueryStatus. + * @param asyncAPIId The AsyncAPI Object to be updated + * @param status Status from Enum QueryStatus + * @param type AsyncAPI Type Implementation. + * @return AsyncAPI Updated AsyncAPI Object + */ + public T updateStatus(String asyncAPIId, QueryStatus status, Class type); + + /** + * This method persists the model for AsyncAPIResult, AsyncAPI object and establishes the relationship. + * @param asyncAPIResult AsyncAPIResult to be associated with the AsyncAPI object + * @param asyncAPIId String + * @param type AsyncAPI Type Implementation. + * @return AsyncAPI Object + */ + public T updateAsyncAPIResult(AsyncAPIResult asyncAPIResult, + String asyncAPIId, Class type); + + /** + * This method deletes a Iterable of AsyncAPI and its associated AsyncAPIResult objects from database + * based on a filter expression, and returns the objects deleted. + * @param filterExpression filter expression to delete AsyncAPI Objects based on + * @param type AsyncAPI Type Implementation. + * @return query object Iterable deleted + */ + public Iterable deleteAsyncAPIAndResultByFilter( + FilterExpression filterExpression, Class type); + + /** + * This method updates the status for a Iterable of AsyncAPI objects from database based on a filter expression, and + * returns the objects updated. + * @param filterExpression filter expression to update AsyncAPI Objects based on + * @param status status to be updated + * @param type AsyncAPI Type Implementation. + * @return query object Iterable updated + */ + public Iterable updateStatusAsyncAPIByFilter(FilterExpression filterExpression, + QueryStatus status, Class type); + /** + * This method gets a Iterable of AsyncAPI objects from database and + * returns the objects. + * @param filterExpression filter expression to cancel AsyncAPI Objects based on + * @param type AsyncAPI Type Implementation. + * @return query object Iterable loaded + */ + public Iterable loadAsyncAPIByFilter(FilterExpression filterExpression, + Class type); +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAO.java b/elide-async/src/main/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAO.java new file mode 100644 index 0000000000..59f1b52917 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAO.java @@ -0,0 +1,200 @@ +/* + * 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.dao; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.Collections; +import java.util.Iterator; +import java.util.UUID; +import javax.inject.Singleton; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Utility class which implements AsyncAPIDAO. + */ +@Singleton +@Slf4j +@Getter +public class DefaultAsyncAPIDAO implements AsyncAPIDAO { + + @Setter private ElideSettings elideSettings; + @Setter private DataStore dataStore; + + // Default constructor is needed for standalone implementation for override in getAsyncAPIDao + public DefaultAsyncAPIDAO() { + } + + public DefaultAsyncAPIDAO(ElideSettings elideSettings, DataStore dataStore) { + this.elideSettings = elideSettings; + this.dataStore = dataStore; + } + + @SuppressWarnings("unchecked") + @Override + public T updateStatus(String asyncAPIId, QueryStatus status, Class type) { + T queryObj = (T) executeInTransaction(dataStore, (tx, scope) -> { + EntityProjection asyncAPIIterable = EntityProjection.builder() + .type(type) + .build(); + T query = (T) tx.loadObject(asyncAPIIterable, asyncAPIId, scope); + query.setStatus(status); + tx.save(query, scope); + return query; + }); + return queryObj; + } + + @Override + public Iterable updateStatusAsyncAPIByFilter(FilterExpression filterExpression, + QueryStatus status, Class type) { + return updateAsyncAPIIterable(filterExpression, asyncAPI -> asyncAPI.setStatus(status), type); + } + + /** + * This method updates a Iterable of AsyncAPI objects from database and + * returns the objects updated. + * @param filterExpression Filter expression to update AsyncAPI Objects based on + * @param updateFunction Functional interface for updating AsyncAPI Object + * @param type AsyncAPI Type Implementation. + * @return query object list updated + */ + @SuppressWarnings("unchecked") + private Iterable updateAsyncAPIIterable(FilterExpression filterExpression, + UpdateQuery updateFunction, Class type) { + log.debug("updateAsyncAPIIterable"); + + Iterable asyncAPIList = null; + asyncAPIList = (Iterable) executeInTransaction(dataStore, + (tx, scope) -> { + EntityProjection asyncAPIIterable = EntityProjection.builder() + .type(type) + .filterExpression(filterExpression) + .build(); + + Iterable loaded = tx.loadObjects(asyncAPIIterable, scope); + Iterator itr = loaded.iterator(); + + while (itr.hasNext()) { + T query = (T) itr.next(); + updateFunction.update(query); + tx.save(query, scope); + } + return loaded; + }); + return asyncAPIList; + } + + @Override + @SuppressWarnings("unchecked") + public Iterable deleteAsyncAPIAndResultByFilter( + FilterExpression filterExpression, Class type) { + log.debug("deleteAsyncAPIAndResultByFilter"); + Iterable asyncAPIList = null; + asyncAPIList = (Iterable) executeInTransaction(dataStore, (tx, scope) -> { + EntityProjection asyncAPIIterable = EntityProjection.builder() + .type(type) + .filterExpression(filterExpression) + .build(); + + Iterable loaded = tx.loadObjects(asyncAPIIterable, scope); + Iterator itr = loaded.iterator(); + + while (itr.hasNext()) { + T query = (T) itr.next(); + if (query != null) { + tx.delete(query, scope); + } + } + return loaded; + }); + return asyncAPIList; + } + + @SuppressWarnings("unchecked") + @Override + public T updateAsyncAPIResult(AsyncAPIResult asyncAPIResult, + String asyncAPIId, Class type) { + log.debug("updateAsyncAPIResult"); + T queryObj = (T) executeInTransaction(dataStore, (tx, scope) -> { + EntityProjection asyncAPIIterable = EntityProjection.builder() + .type(type) + .build(); + T query = (T) tx.loadObject(asyncAPIIterable, asyncAPIId, scope); + query.setResult(asyncAPIResult); + if (query.getStatus().equals(QueryStatus.CANCELLED)) { + query.setStatus(QueryStatus.CANCEL_COMPLETE); + } else if (!(query.getStatus().equals(QueryStatus.CANCEL_COMPLETE))) { + query.setStatus(QueryStatus.COMPLETE); + } + tx.save(query, scope); + return query; + }); + return queryObj; + } + + /** + * 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 (AsyncAPIResult 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, Collections.emptyMap(), UUID.randomUUID(), elideSettings); + result = action.execute(tx, scope); + tx.flush(scope); + tx.commit(scope); + } catch (IOException e) { + log.error("IOException: {}", e.toString()); + throw new IllegalStateException(e); + } + return result; + } + + @Override + @SuppressWarnings("unchecked") + public Iterable loadAsyncAPIByFilter(FilterExpression filterExpression, + Class type) { + Iterable asyncAPIList = null; + log.debug("loadAsyncAPIByFilter"); + try { + asyncAPIList = (Iterable) executeInTransaction(dataStore, (tx, scope) -> { + + EntityProjection asyncAPIIterable = EntityProjection.builder() + .type(type) + .filterExpression(filterExpression) + .build(); + return tx.loadObjects(asyncAPIIterable, scope); + }); + } catch (Exception e) { + log.error("Exception: {}", e.toString()); + throw new IllegalStateException(e); + } + return asyncAPIList; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/dao/Transactional.java b/elide-async/src/main/java/com/yahoo/elide/async/service/dao/Transactional.java new file mode 100644 index 0000000000..477eac5d84 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/dao/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.dao; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreTransaction; + +/** + * 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/dao/UpdateQuery.java b/elide-async/src/main/java/com/yahoo/elide/async/service/dao/UpdateQuery.java new file mode 100644 index 0000000000..3bd74d7314 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/dao/UpdateQuery.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.dao; + +import com.yahoo.elide.async.models.AsyncAPI; + +/** + * Function which will be invoked for updating elide async query base implementation. + * @param AsyncQueryBase Type Implementation. + */ +@FunctionalInterface +public interface UpdateQuery { + public void update(T query); +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngine.java b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngine.java new file mode 100644 index 0000000000..16dc2bbc34 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngine.java @@ -0,0 +1,133 @@ +/* + * 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.storageengine; + +import com.yahoo.elide.async.models.FileExtensionType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.models.TableExportResult; + +import io.reactivex.Observable; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Iterator; +import javax.inject.Singleton; + +/** + * Default implementation of ResultStorageEngine that stores results on local filesystem. + * It supports Async Module to store results with Table Export query. + */ +@Singleton +@Slf4j +@Getter +public class FileResultStorageEngine implements ResultStorageEngine { + @Setter private String basePath; + @Setter private boolean enableExtension; + + /** + * Constructor. + * @param basePath basePath for storing the files. Can be absolute or relative. + * @param enableExtension Enable file extensions. + */ + public FileResultStorageEngine(String basePath, boolean enableExtension) { + this.basePath = basePath; + this.enableExtension = enableExtension; + } + + @Override + public TableExportResult storeResults(TableExport tableExport, Observable result) { + log.debug("store TableExportResults for Download"); + String extension = this.isExtensionEnabled() + ? tableExport.getResultType().getFileExtensionType().getExtension() + : FileExtensionType.NONE.getExtension(); + + TableExportResult exportResult = new TableExportResult(); + try (BufferedWriter writer = getWriter(tableExport.getId(), extension)) { + result + .map(record -> record.concat(System.lineSeparator())) + .subscribe( + recordCharArray -> { + writer.write(recordCharArray); + writer.flush(); + }, + throwable -> { + StringBuilder message = new StringBuilder(); + message.append(throwable.getClass().getCanonicalName()).append(" : "); + message.append(throwable.getMessage()); + exportResult.setMessage(message.toString()); + + throw new IllegalStateException(STORE_ERROR, throwable); + }, + writer::flush + ); + } catch (IOException e) { + throw new IllegalStateException(STORE_ERROR, e); + } + + return exportResult; + } + + @Override + public Observable getResultsByID(String tableExportID) { + log.debug("getTableExportResultsByID"); + + return Observable.using( + () -> getReader(tableExportID), + reader -> Observable.fromIterable(() -> new Iterator() { + private String record = null; + + @Override + public boolean hasNext() { + try { + record = reader.readLine(); + return record != null; + } catch (IOException e) { + throw new IllegalStateException(RETRIEVE_ERROR, e); + } + } + + @Override + public String next() { + if (record != null) { + return record; + } + throw new IllegalStateException("null line found."); + } + }), + BufferedReader::close); + } + + private BufferedReader getReader(String tableExportID) { + try { + return Files.newBufferedReader(Paths.get(basePath + File.separator + tableExportID)); + } catch (IOException e) { + log.debug(e.getMessage()); + throw new IllegalStateException(RETRIEVE_ERROR, e); + } + } + + private BufferedWriter getWriter(String tableExportID, String extension) { + try { + return Files.newBufferedWriter(Paths.get(basePath + File.separator + tableExportID + extension)); + } catch (IOException e) { + log.debug(e.getMessage()); + throw new IllegalStateException(STORE_ERROR, e); + } + } + + @Override + public boolean isExtensionEnabled() { + return this.enableExtension; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngine.java b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngine.java new file mode 100644 index 0000000000..9ffa242c7e --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngine.java @@ -0,0 +1,127 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.async.service.storageengine; + +import com.yahoo.elide.async.models.FileExtensionType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.models.TableExportResult; + +import io.reactivex.Observable; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import redis.clients.jedis.UnifiedJedis; + +import java.util.Iterator; + +import javax.inject.Singleton; + +/** + * Implementation of ResultStorageEngine that stores results on Redis Cluster. + * It supports Async Module to store results with Table Export query. + */ +@Singleton +@Slf4j +@Getter +public class RedisResultStorageEngine implements ResultStorageEngine { + @Setter private UnifiedJedis jedis; + @Setter private boolean enableExtension; + @Setter private long expirationSeconds; + @Setter private long batchSize; + + /** + * Constructor. + * @param jedis Jedis Connection Pool to Redis clusteer. + * @param enableExtension Enable file extensions. + * @param expirationSeconds Expiration Time for results on Redis. + * @param batchSize Batch Size for retrieving from Redis. + */ + public RedisResultStorageEngine(UnifiedJedis jedis, boolean enableExtension, long expirationSeconds, + long batchSize) { + this.jedis = jedis; + this.enableExtension = enableExtension; + this.expirationSeconds = expirationSeconds; + this.batchSize = batchSize; + } + + @Override + public TableExportResult storeResults(TableExport tableExport, Observable result) { + log.debug("store TableExportResults for Download"); + String extension = this.isExtensionEnabled() + ? tableExport.getResultType().getFileExtensionType().getExtension() + : FileExtensionType.NONE.getExtension(); + + TableExportResult exportResult = new TableExportResult(); + String key = tableExport.getId() + extension; + + result + .map(record -> record) + .subscribe( + recordCharArray -> { + jedis.rpush(key, recordCharArray); + }, + throwable -> { + StringBuilder message = new StringBuilder(); + message.append(throwable.getClass().getCanonicalName()).append(" : "); + message.append(throwable.getMessage()); + exportResult.setMessage(message.toString()); + + throw new IllegalStateException(STORE_ERROR, throwable); + } + ); + jedis.expire(key, expirationSeconds); + + return exportResult; + } + + @Override + public Observable getResultsByID(String tableExportID) { + log.debug("getTableExportResultsByID"); + + long recordCount = jedis.llen(tableExportID); + + if (recordCount == 0) { + throw new IllegalStateException(RETRIEVE_ERROR); + } else { + // Workaround for Local variable defined in an enclosing scope must be final or effectively final; + // use Array. + long[] recordRead = {0}; // index to start. + return Observable.fromIterable(() -> new Iterator() { + @Override + public boolean hasNext() { + return recordRead[0] < recordCount; + } + @Override + public String next() { + StringBuilder record = new StringBuilder(); + long end = recordRead[0] + batchSize - 1; // index of last element. + + if (end >= recordCount) { + end = recordCount - 1; + } + + Iterator itr = jedis.lrange(tableExportID, recordRead[0], end).iterator(); + + // Combine the list into a single string. + while (itr.hasNext()) { + String str = itr.next(); + record.append(str).append(System.lineSeparator()); + } + recordRead[0] = end + 1; //index for next element to be read + + // Removing the last line separator because ExportEndPoint will add 1 more. + return record.substring(0, record.length() - System.lineSeparator().length()); + } + }); + } + } + + @Override + public boolean isExtensionEnabled() { + return this.enableExtension; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/ResultStorageEngine.java b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/ResultStorageEngine.java new file mode 100644 index 0000000000..8e1eccd502 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/ResultStorageEngine.java @@ -0,0 +1,41 @@ +/* + * 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.storageengine; + +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.models.TableExportResult; + +import io.reactivex.Observable; + +/** + * Utility interface used for storing the results of AsyncQuery for downloads. + */ +public interface ResultStorageEngine { + public static final String RETRIEVE_ERROR = "Unable to retrieve results."; + public static final String STORE_ERROR = "Unable to store results."; + + /** + * Stores the result of the query. + * @param tableExport TableExport object + * @param result is the observable result obtained by running the query + * @return TableExportResult. + */ + public TableExportResult storeResults(TableExport tableExport, Observable result); + + /** + * Searches for the async query results by ID and returns the record. + * @param tableExportID is the ID of the TableExport. It may include extension too if enabled. + * @return returns the result associated with the tableExportID + */ + public Observable getResultsByID(String tableExportID); + + /** + * Whether the result storage engine has enabled extensions for attachments. + * @return returns whether the file extensions are enabled + */ + public boolean isExtensionEnabled(); +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnable.java b/elide-async/src/main/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnable.java new file mode 100644 index 0000000000..0dd7086fae --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnable.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.thread; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.TransactionRegistry; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.InPredicate; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.google.common.collect.Sets; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; +import java.util.Date; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Runnable for cancelling AsyncAPI transactions + * beyond the max run time or if it has status CANCELLED. + */ +@Slf4j +@Data +@AllArgsConstructor +public class AsyncAPICancelRunnable implements Runnable { + + private int maxRunTimeSeconds; + private Elide elide; + private AsyncAPIDAO asyncAPIDao; + + @Override + public void run() { + cancelAsyncAPI(AsyncQuery.class); + } + + /** + * This method cancels queries based on threshold. + * @param type AsyncAPI Type Implementation. + */ + protected void cancelAsyncAPI(Class type) { + + try { + TransactionRegistry transactionRegistry = elide.getTransactionRegistry(); + + Map runningTransactionMap = transactionRegistry.getRunningTransactions(); + + //Running transaction UUIDs + Set runningTransactionUUIDs = runningTransactionMap.keySet(); + + //Construct filter expression + PathElement statusPathElement = new PathElement(type, QueryStatus.class, "status"); + FilterExpression fltStatusExpression = + new InPredicate(statusPathElement, QueryStatus.CANCELLED, QueryStatus.PROCESSING, + QueryStatus.QUEUED); + + Iterable asyncAPIIterable = + asyncAPIDao.loadAsyncAPIByFilter(fltStatusExpression, type); + + //Active AsyncAPI UUIDs + Set asyncTransactionUUIDs = StreamSupport.stream(asyncAPIIterable.spliterator(), false) + .filter(query -> query.getStatus() == QueryStatus.CANCELLED + || TimeUnit.SECONDS.convert(Math.abs(new Date(System.currentTimeMillis()).getTime() + - query.getCreatedOn().getTime()), TimeUnit.MILLISECONDS) > maxRunTimeSeconds) + .map(query -> UUID.fromString(query.getRequestId())) + .collect(Collectors.toSet()); + + //AsyncAPI UUIDs that have active transactions + Set queryUUIDsToCancel = Sets.intersection(runningTransactionUUIDs, asyncTransactionUUIDs); + + //AsyncAPI IDs that need to be cancelled + Set queryIDsToCancel = queryUUIDsToCancel.stream() + .map(uuid -> StreamSupport + .stream(asyncAPIIterable.spliterator(), false) + .filter(query -> query.getRequestId().equals(uuid.toString())) + .map(T::getId) + .findFirst().orElseThrow(IllegalStateException::new)) + .collect(Collectors.toSet()); + + //Cancel Transactions + queryUUIDsToCancel.stream() + .forEach((uuid) -> { + DataStoreTransaction runningTransaction = transactionRegistry.getRunningTransaction(uuid); + if (runningTransaction != null) { + JsonApiDocument jsonApiDoc = new JsonApiDocument(); + MultivaluedMap queryParams = new MultivaluedHashMap<>(); + RequestScope scope = new RequestScope("", "query", NO_VERSION, jsonApiDoc, + runningTransaction, null, queryParams, Collections.emptyMap(), + uuid, elide.getElideSettings()); + runningTransaction.cancel(scope); + } + }); + + //Change queryStatus for cancelled queries + if (!queryIDsToCancel.isEmpty()) { + PathElement idPathElement = new PathElement(type, String.class, "id"); + FilterExpression fltIdExpression = + new InPredicate(idPathElement, queryIDsToCancel); + asyncAPIDao.updateStatusAsyncAPIByFilter(fltIdExpression, QueryStatus.CANCEL_COMPLETE, + type); + } + } catch (Exception e) { + log.error("Exception in scheduled cancellation: {}", e.toString()); + } + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/thread/AsyncAPICleanerRunnable.java b/elide-async/src/main/java/com/yahoo/elide/async/service/thread/AsyncAPICleanerRunnable.java new file mode 100644 index 0000000000..e91a25f882 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/thread/AsyncAPICleanerRunnable.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.thread; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.async.service.DateUtil; +import com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.filter.predicates.InPredicate; +import com.yahoo.elide.core.filter.predicates.LEPredicate; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.Calendar; +import java.util.Date; + +/** + * Runnable for updating AsyncAPIThread 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 AsyncAPICleanerRunnable implements Runnable { + + private int maxRunTimeMinutes; + private Elide elide; + private int queryCleanupDays; + private AsyncAPIDAO asyncAPIDao; + private DateUtil dateUtil = new DateUtil(); + + @Override + public void run() { + deleteAsyncAPI(AsyncQuery.class); + timeoutAsyncAPI(AsyncQuery.class); + } + + /** + * This method deletes the historical queries based on threshold. + * @param type AsyncAPI Type Implementation. + */ + protected void deleteAsyncAPI(Class type) { + + try { + Date cleanupDate = dateUtil.calculateFilterDate(Calendar.DATE, queryCleanupDays); + PathElement createdOnPathElement = new PathElement(type, Long.class, "createdOn"); + FilterExpression fltDeleteExp = new LEPredicate(createdOnPathElement, cleanupDate); + asyncAPIDao.deleteAsyncAPIAndResultByFilter(fltDeleteExp, type); + } catch (Exception e) { + log.error("Exception in scheduled cleanup: {}", e.toString()); + } + } + + /** + * This method updates the status of long running async query which + * were interrupted due to host crash/app shutdown to TIMEDOUT. + * @param type AsyncAPI Type Implementation. + */ + protected void timeoutAsyncAPI(Class type) { + + try { + Date filterDate = dateUtil.calculateFilterDate(Calendar.MINUTE, maxRunTimeMinutes); + PathElement createdOnPathElement = new PathElement(type, Long.class, "createdOn"); + PathElement statusPathElement = new PathElement(type, String.class, "status"); + FilterPredicate inPredicate = new InPredicate(statusPathElement, QueryStatus.PROCESSING, + QueryStatus.QUEUED); + FilterPredicate lePredicate = new LEPredicate(createdOnPathElement, filterDate); + AndFilterExpression fltTimeoutExp = new AndFilterExpression(inPredicate, lePredicate); + asyncAPIDao.updateStatusAsyncAPIByFilter(fltTimeoutExp, QueryStatus.TIMEDOUT, type); + } catch (Exception e) { + log.error("Exception in scheduled cleanup: {}", e.toString()); + } + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java new file mode 100644 index 0000000000..da68751433 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java @@ -0,0 +1,247 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.export.formatter; + +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.Mockito.mock; +import static org.mockito.Mockito.when; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.async.models.ResultType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.request.Argument; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.jsonapi.models.Resource; +import org.apache.commons.lang3.time.FastDateFormat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; + +public class CSVExportFormatterTest { + public static final String FORMAT = "yyyy-MM-dd'T'HH:mm'Z'"; + private static final FastDateFormat FORMATTER = FastDateFormat.getInstance(FORMAT, TimeZone.getTimeZone("GMT")); + private HashMapDataStore dataStore; + private Elide elide; + private RequestScope scope; + + @BeforeEach + public void setupMocks(@TempDir Path tempDir) { + dataStore = new HashMapDataStore(DefaultClassScanner.getInstance(), TableExport.class.getPackage()); + Map> map = new HashMap<>(); + elide = new Elide( + new ElideSettingsBuilder(dataStore) + .withEntityDictionary(EntityDictionary.builder().checks(map).build()) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .build()); + elide.doScans(); + scope = mock(RequestScope.class); + } + + @Test + public void testResourceToCSV() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + TableExport queryObj = new TableExport(); + String query = "{ tableExport { edges { node { query queryType createdOn} } } }"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + String row = "\"{ tableExport { edges { node { query queryType createdOn} } } }\", \"GRAPHQL_V1_0\"" + + ", \"" + FORMATTER.format(queryObj.getCreatedOn()); + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + attributes.add(Attribute.builder().type(TableExport.class).name("query").alias("query").build()); + attributes.add(Attribute.builder().type(TableExport.class).name("queryType").build()); + attributes.add(Attribute.builder().type(TableExport.class).name("createdOn").build()); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + Map resourceAttributes = new LinkedHashMap<>(); + resourceAttributes.put("query", query); + resourceAttributes.put("queryType", queryObj.getQueryType()); + resourceAttributes.put("createdOn", queryObj.getCreatedOn()); + + Resource resource = new Resource("tableExport", "0", resourceAttributes, null, null, null); + + PersistentResource persistentResource = mock(PersistentResource.class); + when(persistentResource.getObject()).thenReturn(queryObj); + when(persistentResource.getRequestScope()).thenReturn(scope); + when(persistentResource.toResource(any(), any())).thenReturn(resource); + when(scope.getEntityProjection()).thenReturn(projection); + + String output = formatter.format(persistentResource, 1); + assertTrue(output.contains(row)); + } + + @Test + public void testNullResourceToCSV() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + PersistentResource persistentResource = null; + + String output = formatter.format(persistentResource, 1); + assertNull(output); + } + + @Test + public void testNullProjectionHeader() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + + TableExport queryObj = new TableExport(); + + // Prepare EntityProjection + EntityProjection projection = null; + + String output = formatter.preFormat(projection, queryObj); + assertNull(output); + } + + @Test + public void testProjectionWithEmptyAttributeSetHeader() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + + TableExport queryObj = new TableExport(); + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + String output = formatter.preFormat(projection, queryObj); + assertEquals("", output); + } + + @Test + public void testProjectionWithNullAttributesHeader() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + + TableExport queryObj = new TableExport(); + + // Prepare EntityProjection + Set attributes = null; + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + String output = formatter.preFormat(projection, queryObj); + assertEquals("", output); + } + + @Test + public void testHeader() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + + TableExport queryObj = new TableExport(); + String query = "{ tableExport { edges { node { query queryType } } } }"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + attributes.add(Attribute.builder().type(TableExport.class).name("query").alias("query").build()); + attributes.add(Attribute.builder().type(TableExport.class).name("queryType").build()); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + String output = formatter.preFormat(projection, queryObj); + assertEquals("\"query\",\"queryType\"", output); + } + + @Test + public void testHeaderWithNonmatchingAlias() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + + TableExport queryObj = new TableExport(); + String query = "{ tableExport { edges { node { query queryType } } } }"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + attributes.add(Attribute.builder().type(TableExport.class).name("query").alias("foo").build()); + attributes.add(Attribute.builder().type(TableExport.class).name("queryType").build()); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + String output = formatter.preFormat(projection, queryObj); + assertEquals("\"query\",\"queryType\"", output); + } + + @Test + public void testHeaderWithArguments() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + + TableExport queryObj = new TableExport(); + String query = "{ tableExport { edges { node { query queryType } } } }"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + attributes.add(Attribute.builder() + .type(TableExport.class) + .name("query") + .argument(Argument.builder().name("foo").value("bar").build()) + .alias("query").build()); + + attributes.add(Attribute.builder() + .type(TableExport.class) + .argument(Argument.builder().name("foo").value("bar").build()) + .argument(Argument.builder().name("baz").value("boo").build()) + .name("queryType") + .build()); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + String output = formatter.preFormat(projection, queryObj); + assertEquals("\"query(foo=bar)\",\"queryType(foo=bar baz=boo)\"", output); + } + + @Test + public void testHeaderSkip() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, true); + + TableExport queryObj = new TableExport(); + String query = "{ tableExport { edges { node { query queryType } } } }"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + attributes.add(Attribute.builder().type(TableExport.class).name("query").alias("query").build()); + attributes.add(Attribute.builder().type(TableExport.class).name("queryType").build()); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + String output = formatter.preFormat(projection, queryObj); + assertNull(output); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java new file mode 100644 index 0000000000..a329fc6d8e --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.export.formatter; + +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.Mockito.mock; +import static org.mockito.Mockito.when; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.async.models.ResultType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.jsonapi.models.Resource; +import org.apache.commons.lang3.time.FastDateFormat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; + +public class JSONExportFormatterTest { + public static final String FORMAT = "yyyy-MM-dd'T'HH:mm'Z'"; + private static final FastDateFormat FORMATTER = FastDateFormat.getInstance(FORMAT, TimeZone.getTimeZone("GMT")); + private HashMapDataStore dataStore; + private Elide elide; + private RequestScope scope; + + @BeforeEach + public void setupMocks(@TempDir Path tempDir) { + dataStore = new HashMapDataStore(DefaultClassScanner.getInstance(), TableExport.class.getPackage()); + Map> map = new HashMap<>(); + elide = new Elide( + new ElideSettingsBuilder(dataStore) + .withEntityDictionary(EntityDictionary.builder().checks(map).build()) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .build()); + elide.doScans(); + scope = mock(RequestScope.class); + } + + @Test + public void testFormat() { + JSONExportFormatter formatter = new JSONExportFormatter(elide); + TableExport queryObj = new TableExport(); + String query = "{ tableExport { edges { node { query queryType createdOn} } } }"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + String start = "{\"query\":\"{ tableExport { edges { node { query queryType createdOn} } } }\"," + + "\"queryType\":\"GRAPHQL_V1_0\",\"createdOn\":\"" + FORMATTER.format(queryObj.getCreatedOn()) + "\"}"; + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + attributes.add(Attribute.builder().type(TableExport.class).name("query").alias("query").build()); + attributes.add(Attribute.builder().type(TableExport.class).name("queryType").build()); + attributes.add(Attribute.builder().type(TableExport.class).name("createdOn").build()); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + Map resourceAttributes = new LinkedHashMap<>(); + resourceAttributes.put("query", query); + resourceAttributes.put("queryType", queryObj.getQueryType()); + resourceAttributes.put("createdOn", queryObj.getCreatedOn()); + + Resource resource = new Resource("tableExport", "0", resourceAttributes, null, null, null); + PersistentResource persistentResource = mock(PersistentResource.class); + when(persistentResource.getObject()).thenReturn(queryObj); + when(persistentResource.getRequestScope()).thenReturn(scope); + when(persistentResource.toResource(any(), any())).thenReturn(resource); + when(scope.getEntityProjection()).thenReturn(projection); + + String output = formatter.format(persistentResource, 1); + assertTrue(output.contains(start)); + } + + @Test + public void testResourceToJSON() { + JSONExportFormatter formatter = new JSONExportFormatter(elide); + TableExport queryObj = new TableExport(); + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + + String start = "{\"query\":\"{ tableExport { edges { node { query queryType} } } }\"," + + "\"queryType\":\"GRAPHQL_V1_0\"}"; + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + attributes.add(Attribute.builder().type(TableExport.class).name("query").alias("query").build()); + attributes.add(Attribute.builder().type(TableExport.class).name("queryType").build()); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + Map resourceAttributes = new LinkedHashMap<>(); + resourceAttributes.put("query", "{ tableExport { edges { node { query queryType} } } }"); + resourceAttributes.put("queryType", QueryType.GRAPHQL_V1_0); + + Resource resource = new Resource("tableExport", "0", resourceAttributes, null, null, null); + PersistentResource persistentResource = mock(PersistentResource.class); + when(persistentResource.getObject()).thenReturn(queryObj); + when(persistentResource.getRequestScope()).thenReturn(scope); + when(persistentResource.toResource(any(), any())).thenReturn(resource); + when(scope.getEntityProjection()).thenReturn(projection); + + String output = formatter.resourceToJSON(elide.getMapper().getObjectMapper(), persistentResource); + assertTrue(output.contains(start)); + } + + @Test + public void testNullResourceToJSON() { + JSONExportFormatter formatter = new JSONExportFormatter(elide); + PersistentResource persistentResource = null; + + String output = formatter.format(persistentResource, 1); + assertNull(output); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/models/ArtifactGroup.java b/elide-async/src/test/java/com/yahoo/elide/async/models/ArtifactGroup.java new file mode 100644 index 0000000000..d36dbe229a --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/models/ArtifactGroup.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021, 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.Include; + +import java.util.ArrayList; +import java.util.List; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.OneToMany; + +@Include(rootLevel = true, name = "group") +@Entity +public class ArtifactGroup { + @Id + private String name = ""; + + @OneToMany(mappedBy = "group") + private List products = new ArrayList<>(); +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/models/ArtifactProduct.java b/elide-async/src/test/java/com/yahoo/elide/async/models/ArtifactProduct.java new file mode 100644 index 0000000000..90e77a9398 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/models/ArtifactProduct.java @@ -0,0 +1,22 @@ +/* + * Copyright 2021, Yahoo. + * 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.Include; + +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.ManyToOne; + +@Include(name = "product") +@Entity +public class ArtifactProduct { + @Id + private String name = ""; + + @ManyToOne + private ArtifactGroup group = null; +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/models/AsyncQueryTest.java b/elide-async/src/test/java/com/yahoo/elide/async/models/AsyncQueryTest.java new file mode 100644 index 0000000000..21eff10f20 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/models/AsyncQueryTest.java @@ -0,0 +1,33 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertNotNull; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AsyncQueryTest { + + private static Validator validator; + + @BeforeAll + public void setupMocks() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + public void testUUIDGeneration() { + AsyncQuery queryObj = new AsyncQuery(); + assertNotNull(queryObj.getId()); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/AsyncAPIUpdateOperationTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/AsyncAPIUpdateOperationTest.java new file mode 100644 index 0000000000..99d29e1c59 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/AsyncAPIUpdateOperationTest.java @@ -0,0 +1,54 @@ +/* + * 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.operation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.Future; + +public class AsyncAPIUpdateOperationTest { + + private AsyncAPIUpdateOperation updateThread; + private Elide elide; + private AsyncAPI queryObj; + private AsyncAPIResult queryResultObj; + private AsyncAPIDAO asyncAPIDao; + private Future task; + + @BeforeEach + public void setupMocks() { + elide = mock(Elide.class); + queryObj = mock(AsyncAPI.class); + queryResultObj = mock(AsyncAPIResult.class); + updateThread = new AsyncAPIUpdateOperation(elide, task, queryObj, asyncAPIDao); + } + + @Test + public void testAsyncAPIUpdateRunnableSet() { + assertEquals(elide, updateThread.getElide()); + assertEquals(task, updateThread.getTask()); + assertEquals(queryObj, updateThread.getQueryObj()); + assertEquals(asyncAPIDao, updateThread.getAsyncAPIDao()); + } + + public void testUpdateQuery() { + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + when(queryObj.getId()).thenReturn(id); + updateThread.run(); + verify(asyncAPIDao, times(1)).updateAsyncAPIResult(queryResultObj, queryObj.getId(), queryObj.getClass()); + + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperationTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperationTest.java new file mode 100644 index 0000000000..35c21d5de3 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperationTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +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.AsyncQueryResult; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.graphql.QueryRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.Map; + +public class GraphQLAsyncQueryOperationTest { + + private User user; + private Elide elide; + private RequestScope requestScope; + private Map runners = new HashMap<>(); + private QueryRunner runner; + private AsyncExecutorService asyncExecutorService; + + @BeforeEach + public void setupMocks() { + user = mock(User.class); + elide = mock(Elide.class); + requestScope = mock(RequestScope.class); + runner = mock(QueryRunner.class); + runners.put("v1", runner); + asyncExecutorService = mock(AsyncExecutorService.class); + when(asyncExecutorService.getElide()).thenReturn(elide); + when(asyncExecutorService.getRunners()).thenReturn(runners); + when(requestScope.getApiVersion()).thenReturn("v1"); + } + + @Test + public void testProcessQueryGraphQl() throws URISyntaxException { + AsyncQuery queryObj = new AsyncQuery(); + String responseBody = "{\"data\":{\"book\":{\"edges\":[{\"node\":{\"id\":\"1\",\"title\":\"Ender's Game\"}}," + + "{\"node\":{\"id\":\"2\",\"title\":\"Song of Ice and Fire\"}}," + + "{\"node\":{\"id\":\"3\",\"title\":\"For Whom the Bell Tolls\"}}]}}}"; + ElideResponse response = new ElideResponse(200, responseBody); + String query = "{\"query\":\"{ group { edges { node { name commonName description } } } }\",\"variables\":null}"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + + when(runner.run(any(), any(), any(), any(), any())).thenReturn(response); + GraphQLAsyncQueryOperation graphQLOperation = new GraphQLAsyncQueryOperation(asyncExecutorService, queryObj, requestScope); + AsyncQueryResult queryResultObj = (AsyncQueryResult) graphQLOperation.call(); + assertEquals(responseBody, queryResultObj.getResponseBody()); + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals(3, queryResultObj.getRecordCount()); + } + + @Test + public void testProcessQueryGraphQlInvalidResponse() throws URISyntaxException { + AsyncQuery queryObj = new AsyncQuery(); + String responseBody = "ResponseBody"; + ElideResponse response = new ElideResponse(200, responseBody); + String query = "{\"query\":\"{ group { edges { node { name commonName description } } } }\",\"variables\":null}"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + + when(runner.run(any(), any(), any(), any(), any())).thenReturn(response); + GraphQLAsyncQueryOperation graphQLOperation = new GraphQLAsyncQueryOperation(asyncExecutorService, queryObj, requestScope); + AsyncQueryResult queryResultObj = (AsyncQueryResult) graphQLOperation.call(); + assertEquals(responseBody, queryResultObj.getResponseBody()); + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals(0, queryResultObj.getRecordCount()); + } + + @Test + public void testProcessQueryGraphQlRunnerException() { + AsyncQuery queryObj = new AsyncQuery(); + String query = "{\"query\":\"{ group { edges { node { name commonName description } } } }\",\"variables\":null}"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + + when(runner.run(any(), any(), any(), any())).thenThrow(RuntimeException.class); + GraphQLAsyncQueryOperation graphQLOperation = new GraphQLAsyncQueryOperation(asyncExecutorService, queryObj, requestScope); + assertThrows(RuntimeException.class, () -> graphQLOperation.call()); + } + + @Test + public void testProcessQueryGraphQlApiVersionNotSupported() { + AsyncQuery queryObj = new AsyncQuery(); + String query = "{\"query\":\"{ group { edges { node { name commonName description } } } }\",\"variables\":null}"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + + when(requestScope.getApiVersion()).thenReturn("v2"); + GraphQLAsyncQueryOperation graphQLOperation = new GraphQLAsyncQueryOperation(asyncExecutorService, queryObj, requestScope); + assertThrows(InvalidOperationException.class, () -> graphQLOperation.call()); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLTableExportOperationTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLTableExportOperationTest.java new file mode 100644 index 0000000000..6719632b06 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLTableExportOperationTest.java @@ -0,0 +1,226 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.async.export.formatter.JSONExportFormatter; +import com.yahoo.elide.async.models.ArtifactGroup; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.async.models.ResultType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.models.TableExportResult; +import com.yahoo.elide.async.models.security.AsyncAPIInlineChecks; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.async.service.storageengine.FileResultStorageEngine; +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.audit.Slf4jLogger; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.UUID; + +public class GraphQLTableExportOperationTest { + + private HashMapDataStore dataStore; + private User user; + private Elide elide; + private RequestScope requestScope; + private AsyncExecutorService asyncExecutorService; + private ResultStorageEngine engine; + + @BeforeEach + public void setupMocks(@TempDir Path tempDir) { + dataStore = new HashMapDataStore(DefaultClassScanner.getInstance(), + new HashSet<>(Arrays.asList(TableExport.class.getPackage(), ArtifactGroup.class.getPackage()))); + Map> map = new HashMap<>(); + map.put(AsyncAPIInlineChecks.AsyncAPIOwner.PRINCIPAL_IS_OWNER, + AsyncAPIInlineChecks.AsyncAPIOwner.class); + map.put(AsyncAPIInlineChecks.AsyncAPIAdmin.PRINCIPAL_IS_ADMIN, + AsyncAPIInlineChecks.AsyncAPIAdmin.class); + map.put(AsyncAPIInlineChecks.AsyncAPIStatusValue.VALUE_IS_CANCELLED, + AsyncAPIInlineChecks.AsyncAPIStatusValue.class); + map.put(AsyncAPIInlineChecks.AsyncAPIStatusQueuedValue.VALUE_IS_QUEUED, + AsyncAPIInlineChecks.AsyncAPIStatusQueuedValue.class); + elide = new Elide( + new ElideSettingsBuilder(dataStore) + .withEntityDictionary(EntityDictionary.builder().checks(map).build()) + .withAuditLogger(new Slf4jLogger()) + .withExportApiPath("/export") + .build()); + elide.doScans(); + user = mock(User.class); + requestScope = mock(RequestScope.class); + asyncExecutorService = mock(AsyncExecutorService.class); + engine = new FileResultStorageEngine(tempDir.toString(), false); + when(asyncExecutorService.getElide()).thenReturn(elide); + when(requestScope.getApiVersion()).thenReturn(NO_VERSION); + when(requestScope.getUser()).thenReturn(user); + when(requestScope.getElideSettings()).thenReturn(elide.getElideSettings()); + when(requestScope.getBaseUrlEndPoint()).thenReturn("https://elide.io"); + } + + @Test + public void testProcessQuery() throws IOException { + dataPrep(); + TableExport queryObj = new TableExport(); + String query = "{\"query\":\"{ tableExport { edges { node { id principalName} } } }\",\"variables\":null}"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + GraphQLTableExportOperation graphQLOperation = new GraphQLTableExportOperation(new JSONExportFormatter(elide), asyncExecutorService, + queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) graphQLOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("https://elide.io/export/edc4a871-dff2-4054-804e-d80075cf827d", queryResultObj.getUrl().toString()); + assertEquals(1, queryResultObj.getRecordCount()); + } + + @Test + public void testProcessBadEntityQuery() throws IOException { + dataPrep(); + TableExport queryObj = new TableExport(); + String query = "{\"query\":\"{ tableExportInvalid { edges { node { id principalName} } } }\",\"variables\":null}"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + GraphQLTableExportOperation graphQLOperation = new GraphQLTableExportOperation(new JSONExportFormatter(elide), asyncExecutorService, + queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) graphQLOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("Bad Request Body'Unknown entity {tableExportInvalid}.'", queryResultObj.getMessage()); + } + + @Test + public void testProcessBadQuery() throws IOException { + dataPrep(); + TableExport queryObj = new TableExport(); + String query = "{\"query\":\"{ tableExport { edges { node { id principalName} } }\",\"variables\":null}"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + GraphQLTableExportOperation graphQLOperation = new GraphQLTableExportOperation(new JSONExportFormatter(elide), asyncExecutorService, + queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) graphQLOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("Bad Request Body'Can't parse query: { tableExport { edges { node { id principalName} } }'", queryResultObj.getMessage()); + } + + @Test + public void testProcessQueryWithRelationship() { + TableExport queryObj = new TableExport(); + String query = "{\"query\":\"{ group { edges { node { name products {edges { node { name } } } } } } }\", \"variables\":null}"; + String id = "edc4a871-dff2-4194-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + GraphQLTableExportOperation graphQLOperation = new GraphQLTableExportOperation(new JSONExportFormatter(elide), + asyncExecutorService, queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) graphQLOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("Export is not supported for Query that requires traversing Relationships.", + queryResultObj.getMessage()); + assertNull(queryResultObj.getRecordCount()); + assertNull(queryResultObj.getUrl()); + } + + @Test + public void testProcessQueryWithMultipleProjection() { + TableExport queryObj = new TableExport(); + String query = "{\"query\":\"{ tableExport { edges { node { principalName } } } asyncQuery { edges { node { principalName } } } }\",\"variables\":null}"; + String id = "edc4a871-dff2-4094-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + GraphQLTableExportOperation graphQLOperation = new GraphQLTableExportOperation(new JSONExportFormatter(elide), + asyncExecutorService, queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) graphQLOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("Export is only supported for single Query with one root projection.", + queryResultObj.getMessage()); + assertNull(queryResultObj.getRecordCount()); + assertNull(queryResultObj.getUrl()); + } + + @Test + public void testProcessMultipleQuery() { + TableExport queryObj = new TableExport(); + String query = "{\"query\":\"{ tableExport { edges { node { principalName } } } } { asyncQuery { edges { node { principalName } } } }\",\"variables\":null}"; + String id = "edc4a871-dff2-4094-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + GraphQLTableExportOperation graphQLOperation = new GraphQLTableExportOperation(new JSONExportFormatter(elide), + asyncExecutorService, queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) graphQLOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("Export is only supported for single Query with one root projection.", + queryResultObj.getMessage()); + assertNull(queryResultObj.getRecordCount()); + assertNull(queryResultObj.getUrl()); + } + + /** + * Prepping and Storing an TableExport entry to be queried later on. + * @throws IOException IOException + */ + private void dataPrep() throws IOException { + TableExport temp = new TableExport(); + DataStoreTransaction tx = dataStore.beginTransaction(); + RequestScope scope = new RequestScope(null, null, NO_VERSION, null, tx, user, null, Collections.emptyMap(), + UUID.randomUUID(), elide.getElideSettings()); + tx.save(temp, scope); + tx.commit(scope); + tx.close(); + } + + @AfterEach + public void clearDataStore() { + dataStore.cleanseTestData(); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/JSONAPIAsyncQueryOperationTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/JSONAPIAsyncQueryOperationTest.java new file mode 100644 index 0000000000..22afc751b3 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/JSONAPIAsyncQueryOperationTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +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.AsyncQueryResult; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.security.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.URISyntaxException; + +public class JSONAPIAsyncQueryOperationTest { + + private User user; + private Elide elide; + private RequestScope requestScope; + private AsyncExecutorService asyncExecutorService; + + @BeforeEach + public void setupMocks() { + user = mock(User.class); + elide = mock(Elide.class); + requestScope = mock(RequestScope.class); + asyncExecutorService = mock(AsyncExecutorService.class); + when(asyncExecutorService.getElide()).thenReturn(elide); + } + + @Test + public void testProcessQueryJsonApi() throws URISyntaxException { + AsyncQuery queryObj = new AsyncQuery(); + String responseBody = "{\"data\":" + + "[{\"type\":\"book\",\"id\":\"3\",\"attributes\":{\"title\":\"For Whom the Bell Tolls\"}}" + + ",{\"type\":\"book\",\"id\":\"2\",\"attributes\":{\"title\":\"Song of Ice and Fire\"}}," + + "{\"type\":\"book\",\"id\":\"1\",\"attributes\":{\"title\":\"Ender's Game\"}}]}"; + ElideResponse response = new ElideResponse(200, responseBody); + String query = "/group?sort=commonName&fields%5Bgroup%5D=commonName,description"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.JSONAPI_V1_0); + + when(elide.get(any(), any(), any(), any(), any(), any(), any())).thenReturn(response); + JSONAPIAsyncQueryOperation jsonOperation = new JSONAPIAsyncQueryOperation(asyncExecutorService, queryObj, requestScope); + AsyncQueryResult queryResultObj = (AsyncQueryResult) jsonOperation.call(); + assertEquals(responseBody, queryResultObj.getResponseBody()); + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals(3, queryResultObj.getRecordCount()); + } + + @Test + public void testProcessQueryNonSuccessResponse() throws URISyntaxException { + AsyncQuery queryObj = new AsyncQuery(); + String responseBody = "ResponseBody"; + ElideResponse response = new ElideResponse(201, responseBody); + String query = "/group?sort=commonName&fields%5Bgroup%5D=commonName,description"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.JSONAPI_V1_0); + + when(elide.get(any(), any(), any(), any(), any(), any(), any())).thenReturn(response); + JSONAPIAsyncQueryOperation jsonOperation = new JSONAPIAsyncQueryOperation(asyncExecutorService, queryObj, requestScope); + AsyncQueryResult queryResultObj = (AsyncQueryResult) jsonOperation.call(); + assertEquals(responseBody, queryResultObj.getResponseBody()); + assertEquals(201, queryResultObj.getHttpStatus()); + assertNull(queryResultObj.getRecordCount()); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/JsonAPITableExportOperationTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/JsonAPITableExportOperationTest.java new file mode 100644 index 0000000000..037ad5ca3c --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/JsonAPITableExportOperationTest.java @@ -0,0 +1,187 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.async.export.formatter.JSONExportFormatter; +import com.yahoo.elide.async.models.ArtifactGroup; +import com.yahoo.elide.async.models.QueryType; +import com.yahoo.elide.async.models.ResultType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.models.TableExportResult; +import com.yahoo.elide.async.models.security.AsyncAPIInlineChecks; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.async.service.storageengine.FileResultStorageEngine; +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.audit.Slf4jLogger; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.UUID; + +public class JsonAPITableExportOperationTest { + + private HashMapDataStore dataStore; + private User user; + private RequestScope requestScope; + private Elide elide; + private AsyncExecutorService asyncExecutorService; + private ResultStorageEngine engine; + + @BeforeEach + public void setupMocks(@TempDir Path tempDir) { + dataStore = new HashMapDataStore(DefaultClassScanner.getInstance(), + new HashSet<>(Arrays.asList(TableExport.class.getPackage(), ArtifactGroup.class.getPackage()))); + Map> map = new HashMap<>(); + map.put(AsyncAPIInlineChecks.AsyncAPIOwner.PRINCIPAL_IS_OWNER, + AsyncAPIInlineChecks.AsyncAPIOwner.class); + map.put(AsyncAPIInlineChecks.AsyncAPIAdmin.PRINCIPAL_IS_ADMIN, + AsyncAPIInlineChecks.AsyncAPIAdmin.class); + map.put(AsyncAPIInlineChecks.AsyncAPIStatusValue.VALUE_IS_CANCELLED, + AsyncAPIInlineChecks.AsyncAPIStatusValue.class); + map.put(AsyncAPIInlineChecks.AsyncAPIStatusQueuedValue.VALUE_IS_QUEUED, + AsyncAPIInlineChecks.AsyncAPIStatusQueuedValue.class); + + elide = new Elide( + new ElideSettingsBuilder(dataStore) + .withEntityDictionary(EntityDictionary.builder().checks(map).build()) + .withAuditLogger(new Slf4jLogger()) + .withExportApiPath("/export") + .build()); + elide.doScans(); + user = mock(User.class); + requestScope = mock(RequestScope.class); + asyncExecutorService = mock(AsyncExecutorService.class); + engine = new FileResultStorageEngine(tempDir.toString(), true); + when(asyncExecutorService.getElide()).thenReturn(elide); + when(requestScope.getApiVersion()).thenReturn(NO_VERSION); + when(requestScope.getUser()).thenReturn(user); + when(requestScope.getElideSettings()).thenReturn(elide.getElideSettings()); + when(requestScope.getBaseUrlEndPoint()).thenReturn("https://elide.io"); + } + + @Test + public void testProcessQuery() throws IOException { + dataPrep(); + TableExport queryObj = new TableExport(); + String query = "/tableExport?sort=principalName&fields=principalName"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.JSONAPI_V1_0); + queryObj.setResultType(ResultType.CSV); + + JSONAPITableExportOperation jsonAPIOperation = new JSONAPITableExportOperation(new JSONExportFormatter(elide), asyncExecutorService, + queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) jsonAPIOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("https://elide.io/export/edc4a871-dff2-4054-804e-d80075cf827d.csv", queryResultObj.getUrl().toString()); + assertEquals(1, queryResultObj.getRecordCount()); + assertNull(queryResultObj.getMessage()); + } + + @Test + public void testProcessBadEntityQuery() throws IOException { + dataPrep(); + TableExport queryObj = new TableExport(); + String query = "/tableExportInvalid?sort=principalName&fields=principalName"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.JSONAPI_V1_0); + queryObj.setResultType(ResultType.CSV); + + JSONAPITableExportOperation jsonAPIOperation = new JSONAPITableExportOperation(new JSONExportFormatter(elide), asyncExecutorService, + queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) jsonAPIOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("Unknown collection tableExportInvalid", queryResultObj.getMessage()); + } + + @Test + public void testProcessBadQuery() throws IOException { + dataPrep(); + TableExport queryObj = new TableExport(); + String query = "tableExport/^IllegalCharacter^"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.JSONAPI_V1_0); + queryObj.setResultType(ResultType.CSV); + + JSONAPITableExportOperation jsonAPIOperation = new JSONAPITableExportOperation(new JSONExportFormatter(elide), asyncExecutorService, + queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) jsonAPIOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("Illegal character in path at index 12: tableExport/^IllegalCharacter^", + queryResultObj.getMessage()); + } + + @Test + public void testProcessQueryWithRelationship() { + TableExport queryObj = new TableExport(); + String query = "/group?fields[group]=products"; + String id = "edc4a871-dff2-4194-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.JSONAPI_V1_0); + queryObj.setResultType(ResultType.CSV); + + JSONAPITableExportOperation jsonAPIOperation = new JSONAPITableExportOperation(new JSONExportFormatter(elide), + asyncExecutorService, queryObj, requestScope, engine); + TableExportResult queryResultObj = (TableExportResult) jsonAPIOperation.call(); + + assertEquals(200, queryResultObj.getHttpStatus()); + assertEquals("Export is not supported for Query that requires traversing Relationships.", + queryResultObj.getMessage()); + assertNull(queryResultObj.getRecordCount()); + assertNull(queryResultObj.getUrl()); + } + + /** + * Prepping and Storing an TableExport entry to be queried later on. + * @throws IOException IOException + */ + private void dataPrep() throws IOException { + TableExport temp = new TableExport(); + DataStoreTransaction tx = dataStore.beginTransaction(); + RequestScope scope = new RequestScope(null, null, NO_VERSION, null, tx, user, null, Collections.emptyMap(), + UUID.randomUUID(), elide.getElideSettings()); + tx.save(temp, scope); + tx.commit(scope); + tx.close(); + } + + @AfterEach + public void clearDataStore() { + dataStore.cleanseTestData(); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/SafeJsonPathTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/SafeJsonPathTest.java new file mode 100644 index 0000000000..d837c79997 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/SafeJsonPathTest.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.operation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.Test; + +/** + * Tests out different JSONPath parsing results. + */ +public class SafeJsonPathTest { + + @Test + public void testIntegerResult() { + Integer count = AsyncQueryOperation.safeJsonPathLength("{ \"data\": [1,2,3] }", "data.length()"); + + assertEquals(3, count); + } + + @Test + public void testArrayResult() { + Integer count = AsyncQueryOperation.safeJsonPathLength("{ \"data\": [10] }", "data"); + + assertEquals(10, count); + } + + @Test + public void testEmptyArrayResult() { + Integer count = AsyncQueryOperation.safeJsonPathLength("{ \"data\": [] }", "data"); + + assertEquals(0, count); + } + + @Test + public void testInvalidREsult() { + assertThrows(IllegalStateException.class, () -> + AsyncQueryOperation.safeJsonPathLength("{ \"data\": \"foo\" }", "data")); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/resources/ExportApiEndpointTest.java b/elide-async/src/test/java/com/yahoo/elide/async/resources/ExportApiEndpointTest.java new file mode 100644 index 0000000000..50bfec9762 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/resources/ExportApiEndpointTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.resources; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.yahoo.elide.async.resources.ExportApiEndpoint.ExportApiProperties; +import com.yahoo.elide.async.service.storageengine.FileResultStorageEngine; +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.mockito.ArgumentCaptor; +import io.reactivex.Observable; + +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.container.AsyncResponse; +import javax.ws.rs.core.Response; + +/** + * ExportAPiEndpoint Test. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ExportApiEndpointTest { + + private ExportApiEndpoint endpoint; + private ResultStorageEngine engine; + private AsyncResponse asyncResponse; + private HttpServletResponse response; + private ExportApiProperties exportApiProperties; + private ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(Response.class); + + @BeforeEach + public void setup() { + engine = mock(FileResultStorageEngine.class); + asyncResponse = mock(AsyncResponse.class); + response = mock(HttpServletResponse.class); + } + + @Test + public void testGet() { + String queryId = "1"; + int maxDownloadTimeSeconds = 1; + int maxDownloadTimeMilliSeconds = (int) TimeUnit.SECONDS.toMillis(maxDownloadTimeSeconds); + when(engine.getResultsByID(queryId)).thenReturn(Observable.just("result")); + + exportApiProperties = new ExportApiProperties(Executors.newFixedThreadPool(1), maxDownloadTimeSeconds); + endpoint = new ExportApiEndpoint(engine, exportApiProperties); + endpoint.get(queryId, response, asyncResponse); + + // Timeout(int) succeeds as soon as the function to be verified is called. + // It waits maximum upto value of "int" for function to be called. + verify(engine, timeout(maxDownloadTimeMilliSeconds)).getResultsByID(queryId); + verify(asyncResponse, timeout(maxDownloadTimeMilliSeconds)).resume(responseCaptor.capture()); + final Response res = responseCaptor.getValue(); + + assertEquals(res.getStatus(), 200); + } +} 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..b6bcfc8a05 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncCleanerServiceTest.java @@ -0,0 +1,34 @@ +/* + * 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 com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import com.yahoo.elide.async.service.dao.DefaultAsyncAPIDAO; +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); + AsyncAPIDAO dao = mock(DefaultAsyncAPIDAO.class); + AsyncCleanerService.init(elide, 5, 60, 300, 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..4168677083 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncExecutorServiceTest.java @@ -0,0 +1,115 @@ +/* + * 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.dictionary.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 static org.mockito.Mockito.when; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.async.models.AsyncAPIResult; +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.async.operation.JSONAPIAsyncQueryOperation; +import com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import com.yahoo.elide.async.service.dao.DefaultAsyncAPIDAO; +import com.yahoo.elide.async.service.storageengine.FileResultStorageEngine; +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import org.apache.http.NoHttpResponseException; +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; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AsyncExecutorServiceTest { + + private AsyncExecutorService service; + private Elide elide; + private AsyncAPIDAO asyncAPIDao; + private User testUser; + private RequestScope scope; + private ResultStorageEngine resultStorageEngine; + + @BeforeAll + public void setupMockElide() { + HashMapDataStore inMemoryStore = new HashMapDataStore( + DefaultClassScanner.getInstance(), + AsyncQuery.class.getPackage() + ); + Map> checkMappings = new HashMap<>(); + elide = new Elide( + new ElideSettingsBuilder(inMemoryStore) + .withEntityDictionary(EntityDictionary.builder().checks(checkMappings).build()) + .build()); + asyncAPIDao = mock(DefaultAsyncAPIDAO.class); + testUser = mock(User.class); + scope = mock(RequestScope.class); + resultStorageEngine = mock(FileResultStorageEngine.class); + service = new AsyncExecutorService(elide, Executors.newFixedThreadPool(5), Executors.newFixedThreadPool(5), + asyncAPIDao); + + } + + @Test + public void testAsyncExecutorServiceSet() { + assertEquals(elide, service.getElide()); + assertNotNull(service.getRunners()); + assertNotNull(service.getExecutor()); + assertNotNull(service.getUpdater()); + assertEquals(asyncAPIDao, service.getAsyncAPIDao()); + assertEquals(resultStorageEngine, resultStorageEngine); + } + + //Test for executor hook execution + @Test + public void testExecuteQueryFail() throws Exception { + AsyncQuery queryObj = mock(AsyncQuery.class); + when(queryObj.getAsyncAfterSeconds()).thenReturn(10); + + Callable mockCallable = mock(Callable.class); + when(mockCallable.call()).thenThrow(new NoHttpResponseException("")); + + service.executeQuery(queryObj, mockCallable); + verify(queryObj, times(1)).setStatus(QueryStatus.PROCESSING); + verify(queryObj, times(1)).setStatus(QueryStatus.FAILURE); + } + + //Test for executor hook execution + @Test + public void testExecuteQueryComplete() { + + AsyncQuery queryObj = mock(AsyncQuery.class); + String query = "/group?sort=commonName&fields%5Bgroup%5D=commonName,description"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + when(queryObj.getQuery()).thenReturn(query); + when(queryObj.getId()).thenReturn(id); + when(queryObj.getRequestId()).thenReturn(id); + when(queryObj.getQueryType()).thenReturn(QueryType.JSONAPI_V1_0); + when(queryObj.getAsyncAfterSeconds()).thenReturn(10); + when(scope.getApiVersion()).thenReturn(NO_VERSION); + when(scope.getUser()).thenReturn(testUser); + JSONAPIAsyncQueryOperation jsonOperation = new JSONAPIAsyncQueryOperation(service, queryObj, scope); + service.executeQuery(queryObj, jsonOperation); + verify(queryObj, times(1)).setStatus(QueryStatus.PROCESSING); + verify(queryObj, times(1)).setStatus(QueryStatus.COMPLETE); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAOTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAOTest.java new file mode 100644 index 0000000000..caa983ef2a --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAOTest.java @@ -0,0 +1,126 @@ +/* + * 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.dao; + +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.RequestScope; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.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 DefaultAsyncAPIDAOTest { + + private DefaultAsyncAPIDAO asyncAPIDAO; + private Elide elide; + private DataStore dataStore; + private AsyncQuery asyncQuery; + private AsyncQueryResult asyncQueryResult; + private DataStoreTransaction tx; + private FilterExpression filter; + + @BeforeEach + public void setupMocks() { + dataStore = mock(DataStore.class); + asyncQuery = mock(AsyncQuery.class); + asyncQueryResult = mock(AsyncQueryResult.class); + filter = mock(FilterExpression.class); + tx = mock(DataStoreTransaction.class); + + Map> checkMappings = new HashMap<>(); + + EntityDictionary dictionary = EntityDictionary.builder().checks(checkMappings).build(); + dictionary.bindEntity(AsyncQuery.class); + dictionary.bindEntity(AsyncQueryResult.class); + + ElideSettings elideSettings = new ElideSettingsBuilder(dataStore) + .withEntityDictionary(dictionary) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .build(); + + elide = new Elide(elideSettings); + when(dataStore.beginTransaction()).thenReturn(tx); + asyncAPIDAO = new DefaultAsyncAPIDAO(elide.getElideSettings(), dataStore); + } + + @Test + public void testAsyncQueryCleanerThreadSet() { + assertEquals(elide.getElideSettings(), asyncAPIDAO.getElideSettings()); + assertEquals(dataStore, asyncAPIDAO.getDataStore()); + } + + @Test + public void testUpdateStatus() { + when(tx.loadObject(any(), any(), any())).thenReturn(asyncQuery); + asyncAPIDAO.updateStatus("1234", QueryStatus.PROCESSING, asyncQuery.getClass()); + verify(dataStore, times(1)).beginTransaction(); + 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(new DataStoreIterableBuilder(loaded).build()); + asyncAPIDAO.updateStatusAsyncAPIByFilter(filter, QueryStatus.TIMEDOUT, asyncQuery.getClass()); + 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(new DataStoreIterableBuilder(loaded).build()); + asyncAPIDAO.deleteAsyncAPIAndResultByFilter(filter, asyncQuery.getClass()); + 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 testUpdateAsyncQueryResult() { + when(tx.loadObject(any(), any(), any())).thenReturn(asyncQuery); + when(asyncQuery.getStatus()).thenReturn(QueryStatus.PROCESSING); + asyncAPIDAO.updateAsyncAPIResult(asyncQueryResult, asyncQuery.getId(), asyncQuery.getClass()); + verify(dataStore, times(1)).beginTransaction(); + verify(tx, times(1)).save(any(AsyncQuery.class), any(RequestScope.class)); + verify(asyncQuery, times(1)).setResult(asyncQueryResult); + verify(asyncQuery, times(1)).setStatus(QueryStatus.COMPLETE); + + } + + @Test + public void testLoadAsyncQueryCollection() { + Iterable loaded = Arrays.asList(asyncQuery, asyncQuery, asyncQuery); + when(tx.loadObjects(any(), any())).thenReturn(new DataStoreIterableBuilder(loaded).build()); + asyncAPIDAO.loadAsyncAPIByFilter(filter, asyncQuery.getClass()); + verify(dataStore, times(1)).beginTransaction(); + verify(tx, times(1)).loadObjects(any(), any()); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngineTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngineTest.java new file mode 100644 index 0000000000..4f0fb2a45c --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngineTest.java @@ -0,0 +1,89 @@ +/* + * 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.storageengine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.async.models.TableExport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import io.reactivex.Observable; + +import java.io.File; +import java.nio.file.Path; + +/** + * Test cases for FileResultStorageEngine. + */ +public class FileResultStorageEngineTest { + private static final String BASE_PATH = "src/test/resources/downloads/"; + + @Test + public void testRead() { + String finalResult = readResultsFile(BASE_PATH, "non_empty_results"); + assertEquals(finalResult, "test"); + } + + @Test + public void testReadEmptyFile() { + String finalResult = readResultsFile(BASE_PATH, "empty_results"); + assertEquals(finalResult, ""); + } + + @Test + public void testReadNonExistentFile() { + assertThrows(IllegalStateException.class, () -> + readResultsFile(BASE_PATH , "nonexisting_results") + ); + } + + @Test + public void testStoreResults(@TempDir Path tempDir) { + String queryId = "store_results_success"; + String validOutput = "hi\nhello"; + String[] input = validOutput.split("\n"); + + storeResultsFile(tempDir.toString(), queryId, Observable.fromArray(input)); + + File file = new File(tempDir.toString() + File.separator + queryId); + assertTrue(file.exists()); + + // verify contents of stored files are readable and match original + String finalResult = readResultsFile(tempDir.toString(), queryId); + assertEquals(finalResult, validOutput); + } + + // O/P Directory does not exist. + @Test + public void testStoreResultsFail(@TempDir File tempDir) { + assertThrows(IllegalStateException.class, () -> + storeResultsFile(tempDir.toString() + "invalid", "store_results_fail", + Observable.fromArray(new String[]{"hi", "hello"})) + ); + } + + private String readResultsFile(String path, String queryId) { + FileResultStorageEngine engine = new FileResultStorageEngine(path, false); + + return engine.getResultsByID(queryId).collect(() -> new StringBuilder(), + (resultBuilder, tempResult) -> { + if (resultBuilder.length() > 0) { + resultBuilder.append(System.lineSeparator()); + } + resultBuilder.append(tempResult); + } + ).map(StringBuilder::toString).blockingGet(); + } + + private void storeResultsFile(String path, String queryId, Observable storable) { + FileResultStorageEngine engine = new FileResultStorageEngine(path, false); + TableExport query = new TableExport(); + query.setId(queryId); + + engine.storeResults(query, storable); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngineTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngineTest.java new file mode 100644 index 0000000000..3634e25f20 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngineTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service.storageengine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.yahoo.elide.async.models.TableExport; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import io.reactivex.Observable; +import io.reactivex.observers.TestObserver; +import redis.clients.jedis.JedisPooled; +import redis.clients.jedis.exceptions.JedisConnectionException; +import redis.embedded.RedisServer; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +/** + * Test cases for RedisResultStorageEngine. + */ +public class RedisResultStorageEngineTest { + private static final String HOST = "localhost"; + private static final int PORT = 6379; + private static final int EXPIRATION_SECONDS = 120; + private static final int BATCH_SIZE = 2; + private static final boolean EXTENSION_SUPPORT = false; + private JedisPooled jedisPool; + private RedisServer redisServer; + RedisResultStorageEngine engine; + + @BeforeEach + public void setup() throws IOException { + redisServer = new RedisServer(PORT); + redisServer.start(); + jedisPool = new JedisPooled(HOST, PORT); + engine = new RedisResultStorageEngine(jedisPool, EXTENSION_SUPPORT, EXPIRATION_SECONDS, BATCH_SIZE); + } + + @AfterEach + public void destroy() throws IOException { + redisServer.stop(); + } + + @Test + public void testReadNonExistent() { + assertThrows(IllegalStateException.class, () -> + verifyResults("nonexisting_results", Arrays.asList("")) + ); + } + + @Test + public void testStoreEmptyResults() { + String queryId = "store_empty_results_success"; + String validOutput = ""; + String[] input = validOutput.split("\n"); + + storeResults(queryId, Observable.fromArray(input)); + + // verify contents of stored files are readable and match original + verifyResults("store_empty_results_success", Arrays.asList(validOutput)); + } + + @Test + public void testStoreResults() { + String queryId = "store_results_success"; + String validOutput = "hi\nhello"; + String[] input = validOutput.split("\n"); + + storeResults(queryId, Observable.fromArray(input)); + + // verify contents of stored files are readable and match original + verifyResults("store_results_success", Arrays.asList(validOutput)); + } + + // Redis server does not exist. + @Test + public void testStoreResultsFail() throws IOException { + destroy(); + assertThrows(JedisConnectionException.class, () -> + storeResults("store_results_fail", + Observable.fromArray(new String[]{"hi", "hello"})) + ); + } + + @Test + public void testReadResultsBatch() { + String queryId = "store_results_batch_success"; + // 3 records > batchSize i.e 2 + String validOutput = "hi\nhello\nbye"; + String[] input = validOutput.split("\n"); + + storeResults(queryId, Observable.fromArray(input)); + + // 2 onnext calls will be returned. + // 1st call will have 2 records combined together as one. hi and hello. + // 2nd call will have 1 record only. bye. + verifyResults("store_results_batch_success", Arrays.asList("hi\nhello", "bye")); + } + + private void verifyResults(String queryId, List expected) { + TestObserver subscriber = new TestObserver<>(); + + Observable observable = engine.getResultsByID(queryId); + + observable.subscribe(subscriber); + subscriber.assertComplete(); + assertEquals(subscriber.getEvents().iterator().next(), expected); + } + + private void storeResults(String queryId, Observable storable) { + TableExport query = new TableExport(); + query.setId(queryId); + + engine.storeResults(query, storable); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnableTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnableTest.java new file mode 100644 index 0000000000..c79afa6163 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnableTest.java @@ -0,0 +1,151 @@ +/* + * 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.thread; + +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.ElideSettingsBuilder; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import com.yahoo.elide.async.service.dao.DefaultAsyncAPIDAO; +import com.yahoo.elide.core.TransactionRegistry; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; +import java.util.UUID; + +public class AsyncAPICancelRunnableTest { + + private AsyncAPICancelRunnable cancelThread; + private Elide elide; + private AsyncAPIDAO asyncAPIDao; + private TransactionRegistry transactionRegistry; + + @BeforeEach + public void setupMocks() { + HashMapDataStore inMemoryStore = new HashMapDataStore(DefaultClassScanner.getInstance(), + AsyncQuery.class.getPackage()); + Map> checkMappings = new HashMap<>(); + + elide = new Elide( + new ElideSettingsBuilder(inMemoryStore) + .withEntityDictionary(EntityDictionary.builder().checks(checkMappings).build()) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .build()); + + asyncAPIDao = mock(DefaultAsyncAPIDAO.class); + cancelThread = new AsyncAPICancelRunnable(7, elide, asyncAPIDao); + transactionRegistry = elide.getTransactionRegistry(); + + } + + @Test + public void testAsyncQueryCancelThreadSet() { + assertEquals(elide, cancelThread.getElide()); + assertEquals(asyncAPIDao, cancelThread.getAsyncAPIDao()); + assertEquals(7, cancelThread.getMaxRunTimeSeconds()); + } + + @Test + public void testActiveTransactionCancellation() { + DataStoreTransaction dtx = elide.getDataStore().beginTransaction(); + transactionRegistry.addRunningTransaction(UUID.fromString("edc4a871-dff2-4054-804e-d80075cf828d"), dtx); + AsyncQuery asyncQuery1 = createAsyncQueryTestObject("edc4a871-dff2-4054-804e-d80075cf828d", + 1577883600000L, QueryStatus.QUEUED); + AsyncQuery asyncQuery2 = createAsyncQueryTestObject("edc4a871-dff2-4054-804e-d80075cf827d", + 1577883600000L, QueryStatus.QUEUED); + Collection asyncCollection = new ArrayList<>(); + asyncCollection.add(asyncQuery1); + asyncCollection.add(asyncQuery2); + when(cancelThread.getAsyncAPIDao().loadAsyncAPIByFilter(any(), any())).thenReturn(asyncCollection); + cancelThread.cancelAsyncAPI(AsyncQuery.class); + ArgumentCaptor filterCaptor = ArgumentCaptor.forClass(FilterExpression.class); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(QueryStatus.class); + verify(asyncAPIDao, times(1)).loadAsyncAPIByFilter(any(), any()); + verify(asyncAPIDao, times(1)).updateStatusAsyncAPIByFilter(filterCaptor.capture(), statusCaptor.capture(), any()); + assertEquals("asyncQuery.id IN [[edc4a871-dff2-4054-804e-d80075cf828d]]", filterCaptor.getValue().toString()); + assertEquals("CANCEL_COMPLETE", statusCaptor.getValue().toString()); + } + + @Test + public void testStatusBasedFilter() { + DataStoreTransaction dtx = elide.getDataStore().beginTransaction(); + transactionRegistry.addRunningTransaction(UUID.fromString("edc4a871-dff2-4054-804e-d80075cf828d"), dtx); + transactionRegistry.addRunningTransaction(UUID.fromString("edc4a871-dff2-4054-804e-d80075cf827d"), dtx); + transactionRegistry.addRunningTransaction(UUID.fromString("edc4a871-dff2-4054-804e-d80075cf826d"), dtx); + AsyncQuery asyncQuery1 = createAsyncQueryTestObject("edc4a871-dff2-4054-804e-d80075cf828d", + 1577883600000L, QueryStatus.CANCEL_COMPLETE); + AsyncQuery asyncQuery2 = createAsyncQueryTestObject("edc4a871-dff2-4054-804e-d80075cf827d", + 1577883600000L, QueryStatus.CANCELLED); + AsyncQuery asyncQuery3 = createAsyncQueryTestObject("edc4a871-dff2-4054-804e-d80075cf826d", + 1577883600000L, QueryStatus.PROCESSING); + Collection asyncCollection = new ArrayList<>(); + asyncCollection.add(asyncQuery1); + asyncCollection.add(asyncQuery2); + asyncCollection.add(asyncQuery3); + when(cancelThread.getAsyncAPIDao().loadAsyncAPIByFilter(any(), any())).thenReturn(asyncCollection); + cancelThread.cancelAsyncAPI(AsyncQuery.class); + ArgumentCaptor fltStatusCaptor = ArgumentCaptor.forClass(FilterExpression.class); + verify(asyncAPIDao, times(1)).loadAsyncAPIByFilter(fltStatusCaptor.capture(), any()); + assertEquals("asyncQuery.status IN [CANCELLED, PROCESSING, QUEUED]", fltStatusCaptor.getValue().toString()); + verify(asyncAPIDao, times(1)).updateStatusAsyncAPIByFilter(any(), any(), any()); + + } + + @Test + public void testTimeBasedCancellation() { + DataStoreTransaction dtx = elide.getDataStore().beginTransaction(); + transactionRegistry.addRunningTransaction(UUID.fromString("edc4a871-dff2-4054-804e-d80075cf828d"), dtx); + transactionRegistry.addRunningTransaction(UUID.fromString("edc4a871-dff2-4054-804e-d80075cf827d"), dtx); + AsyncQuery asyncQuery1 = createAsyncQueryTestObject("edc4a871-dff2-4054-804e-d80075cf828d", + System.currentTimeMillis(), QueryStatus.QUEUED); + AsyncQuery asyncQuery2 = createAsyncQueryTestObject("edc4a871-dff2-4054-804e-d80075cf827d", + 1577883600000L, QueryStatus.QUEUED); + AsyncQuery asyncQuery3 = createAsyncQueryTestObject("edc4a871-dff2-4054-804e-d80075cf826d", + 1577883600000L, QueryStatus.QUEUED); + Collection asyncCollection = new ArrayList<>(); + asyncCollection.add(asyncQuery1); + asyncCollection.add(asyncQuery2); + asyncCollection.add(asyncQuery3); + when(cancelThread.getAsyncAPIDao().loadAsyncAPIByFilter(any(), any())).thenReturn(asyncCollection); + cancelThread.cancelAsyncAPI(AsyncQuery.class); + ArgumentCaptor filterCaptor = ArgumentCaptor.forClass(FilterExpression.class); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(QueryStatus.class); + verify(asyncAPIDao, times(1)).updateStatusAsyncAPIByFilter(filterCaptor.capture(), statusCaptor.capture(), any()); + assertEquals("asyncQuery.id IN [[edc4a871-dff2-4054-804e-d80075cf827d]]", filterCaptor.getValue().toString()); + assertEquals("CANCEL_COMPLETE", statusCaptor.getValue().toString()); + + } + + public AsyncQuery createAsyncQueryTestObject(String id, Long createdOn, QueryStatus status) { + AsyncQuery asyncQuery = new AsyncQuery(); + asyncQuery.setId(id); + asyncQuery.setRequestId(id); + asyncQuery.setCreatedOn(new Date(createdOn)); + asyncQuery.setStatus(status); + return asyncQuery; + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICleanerRunnableTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICleanerRunnableTest.java new file mode 100644 index 0000000000..b5b8057b9c --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICleanerRunnableTest.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.thread; + +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.ElideSettingsBuilder; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.QueryStatus; +import com.yahoo.elide.async.service.DateUtil; +import com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import com.yahoo.elide.async.service.dao.DefaultAsyncAPIDAO; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; + +public class AsyncAPICleanerRunnableTest { + + private AsyncAPICleanerRunnable cleanerThread; + private Elide elide; + private AsyncAPIDAO asyncAPIDao; + private DateUtil dateUtil; + private Date testDate = new Date(1577883661000L); + + @BeforeEach + public void setupMocks() { + HashMapDataStore inMemoryStore = new HashMapDataStore(DefaultClassScanner.getInstance(), + AsyncQuery.class.getPackage()); + Map> checkMappings = new HashMap<>(); + + elide = new Elide( + new ElideSettingsBuilder(inMemoryStore) + .withEntityDictionary(EntityDictionary.builder().checks(checkMappings).build()) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .build()); + asyncAPIDao = mock(DefaultAsyncAPIDAO.class); + dateUtil = mock(DateUtil.class); + cleanerThread = new AsyncAPICleanerRunnable(7, elide, 7, asyncAPIDao, dateUtil); + } + + @Test + public void testAsyncQueryCleanerThreadSet() { + assertEquals(elide, cleanerThread.getElide()); + assertEquals(asyncAPIDao, cleanerThread.getAsyncAPIDao()); + assertEquals(7, cleanerThread.getMaxRunTimeMinutes()); + assertEquals(7, cleanerThread.getQueryCleanupDays()); + } + + @Test + public void testDeleteAsyncQuery() { + when(dateUtil.calculateFilterDate(Calendar.DATE, cleanerThread.getQueryCleanupDays())).thenReturn(testDate); + ArgumentCaptor filterCaptor = ArgumentCaptor.forClass(FilterExpression.class); + cleanerThread.deleteAsyncAPI(AsyncQuery.class); + verify(asyncAPIDao, times(1)).deleteAsyncAPIAndResultByFilter(filterCaptor.capture(), any()); + assertEquals("asyncQuery.createdOn LE [" + testDate + "]", filterCaptor.getValue().toString()); + } + + @Test + public void testTimeoutAsyncQuery() { + when(dateUtil.calculateFilterDate(Calendar.MINUTE, cleanerThread.getMaxRunTimeMinutes())).thenReturn(testDate); + ArgumentCaptor filterCaptor = ArgumentCaptor.forClass(FilterExpression.class); + cleanerThread.timeoutAsyncAPI(AsyncQuery.class); + verify(asyncAPIDao, times(1)).updateStatusAsyncAPIByFilter(filterCaptor.capture(), any(QueryStatus.class), any()); + assertEquals("(asyncQuery.status IN [PROCESSING, QUEUED] AND asyncQuery.createdOn LE [" + testDate + "])", filterCaptor.getValue().toString()); + } +} diff --git a/elide-async/src/test/resources/downloads/empty_results b/elide-async/src/test/resources/downloads/empty_results new file mode 100644 index 0000000000..e69de29bb2 diff --git a/elide-async/src/test/resources/downloads/non_empty_results b/elide-async/src/test/resources/downloads/non_empty_results new file mode 100644 index 0000000000..9daeafb986 --- /dev/null +++ b/elide-async/src/test/resources/downloads/non_empty_results @@ -0,0 +1 @@ +test diff --git a/elide-contrib/dropwizard-elide/README.md b/elide-contrib/dropwizard-elide/README.md deleted file mode 100644 index 5b6a119042..0000000000 --- a/elide-contrib/dropwizard-elide/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# Dropwizard-Elide - -A [Dropwizard Bundle](http://www.dropwizard.io/0.9.2/docs/manual/core.html#bundles) that allows you to integrate Elide in a [Dropwizard](http://www.dropwizard.io) application. - -# Getting Started - -To integrate Elide into your Dropwizard project, include dropwizard-elide into your project's pom.xml: - -```xml - - com.yahoo.elide - dropwizard-elide - -``` - -# Example - -Inside of the Application class, install the bundle: - -``` -private final ElideBundle elideBundle = new ElideBundle( - Author.class, - Book.class -) { - @Override - public DataSourceFactory getDataSourceFactory(DropwizardElideConfiguration configuration) { - return configuration.getDataSourceFactory(); - } -}; - -@Override -public void initialize(Bootstrap bootstrap) { - bootstrap.addBundle(elideBundle); -} - -@Override -public void run(DropwizardElideConfiguration config, Environment environment) { - environment.jersey().register(JsonApiEndpoint.class); -} -``` -Find more at https://github.com/yahoo/elide/tree/master/elide-example/dropwizard-elide-example diff --git a/elide-contrib/dropwizard-elide/pom.xml b/elide-contrib/dropwizard-elide/pom.xml deleted file mode 100644 index b08d21e2cf..0000000000 --- a/elide-contrib/dropwizard-elide/pom.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - - 4.0.0 - dropwizard-elide - jar - Dropwizard Elide - Elide Bundle for Dropwizard - https://github.com/yahoo/elide - - elide-contrib-parent-pom - com.yahoo.elide - 2.3.14-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 - - - - 0.9.2 - - - - - io.dropwizard - dropwizard-core - ${dropwizard.version} - provided - - - io.dropwizard - dropwizard-db - ${dropwizard.version} - provided - - - com.yahoo.elide - elide-core - - - com.yahoo.elide - elide-datastore-hibernate5 - 2.3.14-SNAPSHOT - - - org.eclipse.persistence - javax.persistence - 2.1.0 - - - org.hibernate - hibernate-entitymanager - 5.0.2.Final - - - - - org.testng - testng - test - - - org.mockito - mockito-core - test - - - - - - - org.codehaus.mojo - build-helper-maven-plugin - - - org.apache.maven.plugins - maven-dependency-plugin - - - com.jcabi - jcabi-mysql-maven-plugin - - - org.apache.maven.plugins - maven-surefire-plugin - - - org.apache.maven.plugins - maven-checkstyle-plugin - - - org.apache.maven.plugins - maven-compiler-plugin - - 1.8 - 1.8 - - - - - diff --git a/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/ElideBundle.java b/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/ElideBundle.java deleted file mode 100644 index 4d6a8c405a..0000000000 --- a/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/ElideBundle.java +++ /dev/null @@ -1,177 +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.contrib.dropwizard.elide; - -import com.google.common.collect.ImmutableList; -import com.yahoo.elide.Elide; -import com.yahoo.elide.audit.AuditLogger; -import com.yahoo.elide.core.DataStore; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.datastores.hibernate5.HibernateStore; -import com.yahoo.elide.jsonapi.JsonApiMapper; -import com.yahoo.elide.resources.JsonApiEndpoint; -import com.yahoo.elide.security.PermissionExecutor; -import io.dropwizard.Configuration; -import io.dropwizard.ConfiguredBundle; -import io.dropwizard.db.DatabaseConfiguration; -import io.dropwizard.db.PooledDataSourceFactory; -import io.dropwizard.setup.Bootstrap; -import io.dropwizard.setup.Environment; -import org.glassfish.hk2.utilities.binding.AbstractBinder; -import org.glassfish.jersey.server.internal.scanning.AnnotationAcceptingListener; -import org.glassfish.jersey.server.internal.scanning.PackageNamesScanner; -import org.hibernate.SessionFactory; - -import javax.persistence.Entity; -import java.io.IOException; -import java.io.InputStream; -import java.util.function.Function; - -/** - * Elide Bundle - * - * @param Dropwizard Configuration - */ -public abstract class ElideBundle - implements ConfiguredBundle, DatabaseConfiguration, ElideConfiguration { - - public static final String DEFAULT_NAME = "elide-bundle"; - - private final ImmutableList> entities; - private final SessionFactoryFactory sessionFactoryFactory; - - /** - * @param pckg string with package containing Hibernate entities (classes annotated with Hibernate {@code @Entity} - * annotation) e. g. {@code com.codahale.fake.db.directory.entities} - */ - protected ElideBundle(String pckg) { - this(pckg, new SessionFactoryFactory()); - } - - protected ElideBundle(String pckg, SessionFactoryFactory sessionFactoryFactory) { - this(new String[] { pckg }, sessionFactoryFactory); - } - - protected ElideBundle(String[] pckgs, SessionFactoryFactory sessionFactoryFactory) { - this(findEntityClassesFromDirectory(pckgs), sessionFactoryFactory); - } - - public ElideBundle(Class entity, Class... entities) { - this(ImmutableList.>builder().add(entity).add(entities).build(), - new SessionFactoryFactory()); - } - - public ElideBundle(ImmutableList> entities, - SessionFactoryFactory sessionFactoryFactory) { - this.entities = entities; - this.sessionFactoryFactory = sessionFactoryFactory; - } - - protected String name() { - return DEFAULT_NAME; - } - - @Override - public void initialize(Bootstrap bootstrap) { - } - - @Override - public void run(T configuration, Environment environment) throws Exception { - final DataStore dataStore = getDataStore(configuration, environment); - final AuditLogger auditLogger = getAuditLogger(configuration, environment); - final EntityDictionary entityDictionary = getEntityDictionary(configuration, environment); - final JsonApiMapper jsonApiMapper = getJsonApiMapper(configuration, environment); - final Function permissionExecutor - = getPermissionExecutor(configuration, environment); - - final JsonApiEndpoint.DefaultOpaqueUserFunction getUserFn = getUserFn(configuration, environment); - - environment.jersey().register(new AbstractBinder() { - @Override - protected void configure() { - Elide.Builder builder = new Elide.Builder(dataStore); - if (auditLogger != null) { - builder = builder.withAuditLogger(auditLogger); - } - - if (entityDictionary != null) { - builder = builder.withEntityDictionary(entityDictionary); - } - - if (jsonApiMapper != null) { - builder = builder.withJsonApiMapper(jsonApiMapper); - } - - if (permissionExecutor != null) { - builder = builder.withPermissionExecutor(permissionExecutor); - } - bind(builder.build()).to(Elide.class).named("elide"); - - bind(getUserFn) - .to(JsonApiEndpoint.DefaultOpaqueUserFunction.class) - .named("elideUserExtractionFunction"); - } - }); - } - - protected void configure(org.hibernate.cfg.Configuration configuration) { - } - - /** - * By default created a HibernateStore - * - * @param configuration Dropwizard configuration - * @param environment Dropwizard environment - * @return a HibernateStore - */ - @Override - public DataStore getDataStore(T configuration, Environment environment) { - final PooledDataSourceFactory dbConfig = getDataSourceFactory(configuration); - SessionFactory sessionFactory = sessionFactoryFactory.build(this, environment, dbConfig, entities, name()); - - return new HibernateStore(sessionFactory); - } - - public ImmutableList> getEntities() { - return entities; - } - - public SessionFactoryFactory getSessionFactoryFactory() { - return sessionFactoryFactory; - } - - /** - * Method scanning given directory for classes containing Hibernate @Entity annotation - * - * @param pckgs string array with packages containing Hibernate entities (classes annotated with @Entity annotation) - * e.g. com.codahale.fake.db.directory.entities - * @return ImmutableList with classes from given directory annotated with Hibernate @Entity annotation - */ - public static ImmutableList> findEntityClassesFromDirectory(String[] pckgs) { - @SuppressWarnings("unchecked") - final AnnotationAcceptingListener asl = new AnnotationAcceptingListener(Entity.class); - final PackageNamesScanner scanner = new PackageNamesScanner(pckgs, true); - - while (scanner.hasNext()) { - final String next = scanner.next(); - if (asl.accept(next)) { - try (final InputStream in = scanner.open()) { - asl.process(next, in); - } catch (IOException e) { - throw new RuntimeException("AnnotationAcceptingListener failed to process scanned resource: " - + next); - } - } - } - - final ImmutableList.Builder> builder = ImmutableList.builder(); - asl.getAnnotatedClasses().forEach(builder::add); - - return builder.build(); - } -} diff --git a/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/ElideConfiguration.java b/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/ElideConfiguration.java deleted file mode 100644 index 7ac9441da6..0000000000 --- a/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/ElideConfiguration.java +++ /dev/null @@ -1,96 +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.contrib.dropwizard.elide; - -import com.yahoo.elide.audit.AuditLogger; -import com.yahoo.elide.core.DataStore; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.jsonapi.JsonApiMapper; -import com.yahoo.elide.resources.JsonApiEndpoint; -import com.yahoo.elide.security.PermissionExecutor; -import io.dropwizard.Configuration; -import io.dropwizard.setup.Environment; - -import java.util.function.Function; - -/** - * Elide Configuration - * - * @param Dropwizard Configuration - */ -public interface ElideConfiguration { - /** - * Get the AuditLogger for Elide - * - * Override this method to plug in your own AuditLogger - * - * @param configuration Dropwizard configuration - * @param environment Dropwizard environment - * @return auditLogger to be used in Elide - */ - default AuditLogger getAuditLogger(T configuration, Environment environment) { - return null; - } - - /** - * Get the DefaultOpaqueUserFunction for Elide - * - * Override this method to plug in your own DefaultOpaqueUserFunction - * - * @param configuration Dropwizard configuration - * @param environment Dropwizard environment - * @return defaultOpaqueUserFunction to be used in Elide - */ - default JsonApiEndpoint.DefaultOpaqueUserFunction getUserFn(T configuration, Environment environment) { - return v -> null; - } - - /** - * Get the DataStore for Elide - * - * Override this method to plug in your own DataStore - * - * @param configuration Dropwizard configuration - * @param environment Dropwizard environment - * @return dataStore to be used in Elide - */ - DataStore getDataStore(T configuration, Environment environment); - - /** - * Get the EntityDictionary for Elide - * - * Override this method to plug in your own EntityDictionary - * - * @param configuration Dropwizard Configuration - * @param environment Dropwizard Environment - * @return entityDictionary to be used in Elide - */ - default EntityDictionary getEntityDictionary(T configuration, Environment environment) { - return null; - } - - /** - * - * @param configuration Dropwizard Configuration - * @param environment Dropwizard Environment - * @return jsonApiMapper to be used in Elide - */ - default JsonApiMapper getJsonApiMapper(T configuration, Environment environment) { - return null; - } - - /** - * - * @param configuration Dropwizard Configuration - * @param environment Dropwizard Environment - * @return permissionExecutor function to be used in Elide - */ - default Function getPermissionExecutor(T configuration, Environment environment) { - return null; - } -} diff --git a/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/SessionFactoryFactory.java b/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/SessionFactoryFactory.java deleted file mode 100644 index e3945bdb66..0000000000 --- a/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/SessionFactoryFactory.java +++ /dev/null @@ -1,123 +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.contrib.dropwizard.elide; - -import io.dropwizard.db.ManagedDataSource; -import io.dropwizard.db.PooledDataSourceFactory; -import io.dropwizard.setup.Environment; -import org.hibernate.SessionFactory; -import org.hibernate.boot.registry.StandardServiceRegistryBuilder; -import org.hibernate.cfg.AvailableSettings; -import org.hibernate.cfg.Configuration; -import org.hibernate.engine.jdbc.connections.internal.DatasourceConnectionProviderImpl; -import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider; -import org.hibernate.service.ServiceRegistry; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.sql.DataSource; -import java.util.List; -import java.util.Map; -import java.util.SortedSet; -import java.util.TreeSet; - -/** - * Factory for SessionFactory - * - * Similar to the SessionFactoryFactory in dropwizard-hibernate - */ -public class SessionFactoryFactory { - private static final Logger LOGGER = LoggerFactory.getLogger(SessionFactoryFactory.class); - private static final String DEFAULT_NAME = "hibernate"; - - public SessionFactory build(ElideBundle bundle, - Environment environment, - PooledDataSourceFactory dbConfig, - List> entities) { - return build(bundle, environment, dbConfig, entities, DEFAULT_NAME); - } - - public SessionFactory build(ElideBundle bundle, - Environment environment, - PooledDataSourceFactory dbConfig, - List> entities, - String name) { - final ManagedDataSource dataSource = dbConfig.build(environment.metrics(), name); - return build(bundle, environment, dbConfig, dataSource, entities); - } - - public SessionFactory build(ElideBundle bundle, - Environment environment, - PooledDataSourceFactory dbConfig, - ManagedDataSource dataSource, - List> entities) { - final ConnectionProvider provider = buildConnectionProvider(dataSource, - dbConfig.getProperties()); - final SessionFactory factory = buildSessionFactory(bundle, - dbConfig, - provider, - dbConfig.getProperties(), - entities); - final SessionFactoryManager managedFactory = new SessionFactoryManager(factory, dataSource); - environment.lifecycle().manage(managedFactory); - return factory; - } - - private ConnectionProvider buildConnectionProvider(DataSource dataSource, - Map properties) { - final DatasourceConnectionProviderImpl connectionProvider = new DatasourceConnectionProviderImpl(); - connectionProvider.setDataSource(dataSource); - connectionProvider.configure(properties); - return connectionProvider; - } - - private SessionFactory buildSessionFactory(ElideBundle bundle, - PooledDataSourceFactory dbConfig, - ConnectionProvider connectionProvider, - Map properties, - List> entities) { - final Configuration configuration = new Configuration(); - configuration.setProperty(AvailableSettings.CURRENT_SESSION_CONTEXT_CLASS, "managed"); - configuration.setProperty(AvailableSettings.USE_SQL_COMMENTS, - Boolean.toString(dbConfig.isAutoCommentsEnabled())); - configuration.setProperty(AvailableSettings.USE_GET_GENERATED_KEYS, "true"); - configuration.setProperty(AvailableSettings.GENERATE_STATISTICS, "true"); - configuration.setProperty(AvailableSettings.USE_REFLECTION_OPTIMIZER, "true"); - configuration.setProperty(AvailableSettings.ORDER_UPDATES, "true"); - configuration.setProperty(AvailableSettings.ORDER_INSERTS, "true"); - configuration.setProperty(AvailableSettings.USE_NEW_ID_GENERATOR_MAPPINGS, "true"); - configuration.setProperty("jadira.usertype.autoRegisterUserTypes", "true"); - for (Map.Entry property : properties.entrySet()) { - configuration.setProperty(property.getKey(), property.getValue()); - } - - addAnnotatedClasses(configuration, entities); - bundle.configure(configuration); - - final ServiceRegistry registry = new StandardServiceRegistryBuilder() - .addService(ConnectionProvider.class, connectionProvider) - .applySettings(configuration.getProperties()) - .build(); - - configure(configuration, registry); - - return configuration.buildSessionFactory(registry); - } - - protected void configure(Configuration configuration, ServiceRegistry registry) { - } - - private void addAnnotatedClasses(Configuration configuration, - Iterable> entities) { - final SortedSet entityClasses = new TreeSet<>(); - for (Class klass : entities) { - configuration.addAnnotatedClass(klass); - entityClasses.add(klass.getCanonicalName()); - } - LOGGER.info("Entity classes: {}", entityClasses); - } -} diff --git a/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/SessionFactoryManager.java b/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/SessionFactoryManager.java deleted file mode 100644 index 74818d09aa..0000000000 --- a/elide-contrib/dropwizard-elide/src/main/java/com/yahoo/elide/contrib/dropwizard/elide/SessionFactoryManager.java +++ /dev/null @@ -1,43 +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.contrib.dropwizard.elide; - -import com.google.common.annotations.VisibleForTesting; -import io.dropwizard.db.ManagedDataSource; -import io.dropwizard.lifecycle.Managed; -import org.hibernate.SessionFactory; - -/** - * Managed SessionFactory - * - * Same as the SessionFactoryManager in dropwizard-hibernate - */ -public class SessionFactoryManager implements Managed { - private final SessionFactory factory; - private final ManagedDataSource dataSource; - - public SessionFactoryManager(SessionFactory factory, ManagedDataSource dataSource) { - this.factory = factory; - this.dataSource = dataSource; - } - - @VisibleForTesting - ManagedDataSource getDataSource() { - return dataSource; - } - - @Override - public void start() throws Exception { - dataSource.start(); - } - - @Override - public void stop() throws Exception { - factory.close(); - dataSource.stop(); - } -} diff --git a/elide-contrib/dropwizard-elide/src/test/java/com/yahoo/elide/contrib/dropwizard/elide/Author.java b/elide-contrib/dropwizard-elide/src/test/java/com/yahoo/elide/contrib/dropwizard/elide/Author.java deleted file mode 100644 index a9cc839ce5..0000000000 --- a/elide-contrib/dropwizard-elide/src/test/java/com/yahoo/elide/contrib/dropwizard/elide/Author.java +++ /dev/null @@ -1,59 +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.contrib.dropwizard.elide; - -import com.yahoo.elide.annotation.Include; -import com.yahoo.elide.annotation.SharePermission; -import com.yahoo.elide.security.checks.prefab.Role; - -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.ManyToMany; -import javax.persistence.Table; -import java.util.ArrayList; -import java.util.Collection; - -/** - * Model for authors - */ -@Entity -@SharePermission(any = {Role.ALL.class}) -@Table(name = "author") -@Include(rootLevel = true) -public class Author { - private long id; - private String name; - private Collection books = new ArrayList<>(); - - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - @ManyToMany(mappedBy = "authors") - public Collection getBooks() { - return books; - } - - public void setBooks(Collection books) { - this.books = books; - } -} diff --git a/elide-contrib/dropwizard-elide/src/test/java/com/yahoo/elide/contrib/dropwizard/elide/Book.java b/elide-contrib/dropwizard-elide/src/test/java/com/yahoo/elide/contrib/dropwizard/elide/Book.java deleted file mode 100644 index 56a56e7779..0000000000 --- a/elide-contrib/dropwizard-elide/src/test/java/com/yahoo/elide/contrib/dropwizard/elide/Book.java +++ /dev/null @@ -1,74 +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.contrib.dropwizard.elide; - -import com.yahoo.elide.annotation.Include; - -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.ManyToMany; -import javax.persistence.Table; -import java.util.ArrayList; -import java.util.Collection; - -/** - * Model for books - */ -@Entity -@Table(name = "book") -@Include(rootLevel = true) -public class Book { - private long id; - private String title; - private String genre; - private String language; - private Collection authors = new ArrayList<>(); - - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getGenre() { - return genre; - } - - public void setGenre(String genre) { - this.genre = genre; - } - - public String getLanguage() { - return language; - } - - public void setLanguage(String language) { - this.language = language; - } - - @ManyToMany - public Collection getAuthors() { - return authors; - } - - public void setAuthors(Collection authors) { - this.authors = authors; - } -} diff --git a/elide-contrib/dropwizard-elide/src/test/java/com/yahoo/elide/contrib/dropwizard/elide/ElideBundleTest.java b/elide-contrib/dropwizard-elide/src/test/java/com/yahoo/elide/contrib/dropwizard/elide/ElideBundleTest.java deleted file mode 100644 index d43294bd95..0000000000 --- a/elide-contrib/dropwizard-elide/src/test/java/com/yahoo/elide/contrib/dropwizard/elide/ElideBundleTest.java +++ /dev/null @@ -1,75 +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.contrib.dropwizard.elide; - -import com.codahale.metrics.health.HealthCheckRegistry; -import com.google.common.collect.ImmutableList; -import com.yahoo.elide.datastores.hibernate5.HibernateStore; -import io.dropwizard.Configuration; -import io.dropwizard.db.DataSourceFactory; -import io.dropwizard.jersey.DropwizardResourceConfig; -import io.dropwizard.jersey.setup.JerseyEnvironment; -import io.dropwizard.setup.Environment; -import org.hibernate.SessionFactory; -import org.testng.Assert; -import org.testng.annotations.BeforeTest; -import org.testng.annotations.Test; - -import javax.ws.rs.core.SecurityContext; - -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anyList; -import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.*; - -public class ElideBundleTest { - private final DataSourceFactory dbConfig = new DataSourceFactory(); - private final ImmutableList> entities = ImmutableList.of(Author.class, Book.class); - private final SessionFactoryFactory factory = mock(SessionFactoryFactory.class); - private final SessionFactory sessionFactory = mock(SessionFactory.class); - private final Configuration configuration = mock(Configuration.class); - private final HealthCheckRegistry healthChecks = mock(HealthCheckRegistry.class); - private final JerseyEnvironment jerseyEnvironment = mock(JerseyEnvironment.class); - private final Environment environment = mock(Environment.class); - private final ElideBundle bundle = new ElideBundle(entities, factory) { - @Override - public DataSourceFactory getDataSourceFactory(Configuration configuration) { - return dbConfig; - } - }; - - @BeforeTest - @SuppressWarnings("unchecked") - public void setUp() throws Exception { - when(environment.healthChecks()).thenReturn(healthChecks); - when(environment.jersey()).thenReturn(jerseyEnvironment); - when(jerseyEnvironment.getResourceConfig()).thenReturn(new DropwizardResourceConfig()); - - when(factory.build(eq(bundle), - any(Environment.class), - any(DataSourceFactory.class), - anyList(), - eq("hibernate"))).thenReturn(sessionFactory); - } - - @Test - public void buildsASessionFactory() throws Exception { - bundle.run(configuration, environment); - - verify(factory).build(bundle, environment, dbConfig, entities, "elide-bundle"); - } - - @Test - public void defaultGetUserFnDidNothing() throws Exception { - Assert.assertNull(bundle.getUserFn(configuration, environment).apply(mock(SecurityContext.class))); - } - - @Test - public void hasADataStore() throws Exception { - Assert.assertTrue(bundle.getDataStore(configuration, environment) instanceof HibernateStore); - } -} diff --git a/elide-contrib/dropwizard-elide/testng.xml b/elide-contrib/dropwizard-elide/testng.xml deleted file mode 100644 index 7e4e7122bc..0000000000 --- a/elide-contrib/dropwizard-elide/testng.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - diff --git a/elide-contrib/pom.xml b/elide-contrib/pom.xml deleted file mode 100644 index 5b6d4bf428..0000000000 --- a/elide-contrib/pom.xml +++ /dev/null @@ -1,182 +0,0 @@ - - - - 4.0.0 - elide-contrib-parent-pom - pom - Elide Contrib: Parent Pom - Parent pom for contrib packages - https://github.com/yahoo/elide - - elide-parent-pom - com.yahoo.elide - 2.3.14-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 - - - - ${project.basedir}/../.. - ${project.build.directory}/mysql-data - - - - dropwizard-elide - - - - - - com.yahoo.elide - elide-core - 2.3.14-SNAPSHOT - - - - - - - - org.codehaus.mojo - build-helper-maven-plugin - 1.10 - - - add-source-generate-sources - generate-sources - - add-source - - - - target/generated-sources/antlr4 - - - - - reserve-network-port-pre-integration-test - pre-integration-test - - reserve-network-port - - - - mysql.port - - - - - reserve-network-port-process-test-classes - process-test-classes - - reserve-network-port - - - - restassured.port - - - - - - - org.apache.maven.plugins - maven-dependency-plugin - 2.10 - - - pre-integration-test - - unpack - - - - - com.jcabi - mysql-dist - 5.6.14 - ${mysql.classifier} - zip - false - ${project.build.directory}/mysql-dist - - - - - - - - com.jcabi - jcabi-mysql-maven-plugin - 0.9 - - - mysql-test - - classify - start - stop - - - ${mysql.port} - ${mysql.data.directory} - - - - - - org.apache.maven.plugins - maven-failsafe-plugin - 2.19 - - - ${mysql.port} - ${restassured.port} - ${dataStoreSupplier} - - - ${project.basedir}/../testng.xml - - - - - integration-test - - integration-test - - - - verify - - verify - - - - - - - - diff --git a/elide-core/README.md b/elide-core/README.md index 043acfd6fb..e23efa5284 100644 --- a/elide-core/README.md +++ b/elide-core/README.md @@ -1,4 +1,3 @@ elide-core ========== -[![Dependency Status](https://www.versioneye.com/user/projects/5621503e36d0ab0016000a41/badge.svg?style=flat)](https://www.versioneye.com/user/projects/5621503e36d0ab0016000a41) diff --git a/elide-core/pom.xml b/elide-core/pom.xml index 624f914f34..a13feeb0d6 100644 --- a/elide-core/pom.xml +++ b/elide-core/pom.xml @@ -14,7 +14,7 @@ com.yahoo.elide elide-parent-pom - 2.3.14-SNAPSHOT + 6.1.4-SNAPSHOT @@ -44,10 +44,6 @@ - - com.yahoo.elide - elide-annotations - org.projectlombok lombok @@ -59,17 +55,16 @@ org.antlr antlr4-runtime - 4.5.1-1 + ${version.antlr4} javax.ws.rs javax.ws.rs-api - 2.0.1 + 2.1.1 javax.persistence - persistence-api - 1.0.2 + javax.persistence-api provided @@ -83,7 +78,7 @@ commons-beanutils commons-beanutils - 1.9.2 + 1.9.4 javax.inject @@ -92,7 +87,6 @@ com.fasterxml.jackson.core jackson-databind - 2.6.3 provided @@ -100,6 +94,18 @@ rsql-parser 2.1.0 + + io.reactivex.rxjava2 + rxjava + 2.2.21 + + + + + javax.validation + validation-api + 2.0.1.Final + @@ -120,23 +126,61 @@ org.fusesource.jansi jansi - 1.12 + 2.4.0 - - ch.qos.logback - logback-classic + io.github.classgraph + classgraph + 4.8.139 + + + + org.owasp.encoder + encoder + 1.2.3 + + + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.junit.jupiter + junit-jupiter-params test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + com.google.inject + guice + 5.1.0 + test + + ch.qos.logback - logback-core + logback-classic test - org.testng - testng + ch.qos.logback + logback-core test @@ -147,6 +191,7 @@ org.eclipse.jetty jetty-webapp + ${version.jetty} test @@ -154,11 +199,6 @@ jersey-container-servlet test - - org.codehaus.groovy - groovy-all - test - @@ -166,7 +206,7 @@ org.antlr antlr4-maven-plugin - 4.5.1-1 + ${version.antlr4} true @@ -179,6 +219,25 @@ + + org.apache.maven.plugins + maven-javadoc-plugin + 3.3.2 + + + attach-javadocs + + jar + + + + + all,-missing + ${source.jdk.version} + ${delombok.output} + ${project.basedir}/target/generated-sources/antlr4 + + org.apache.maven.plugins maven-surefire-plugin diff --git a/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Core.g4 b/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Core.g4 index ecab5a80f1..5d72bc06f6 100644 --- a/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Core.g4 +++ b/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Core.g4 @@ -1,17 +1,7 @@ /* - * Copyright (c) 2015 Yahoo! Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. See accompanying LICENSE file. + * Copyright 2015, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. */ grammar Core; @@ -40,30 +30,39 @@ relationship: RELATIONSHIPS '/' term; query: ; // Visitor performs query and outputs result +id: IDSTR | PATHSTR; term: PATHSTR; -id: PATHSTR; RELATIONSHIPS: 'relationships'; -PATHSTR: UNRESERVED+; +PATHSTR: ALPHA ( ALPHANUM | UNDERSCORE | HYPHEN )+; +IDSTR: UNRESERVED+; UNRESERVED : ALPHANUM | MARK + | UNDERSCORE + | HYPHEN ; MARK - : '-' - | '_' - | '.' + : '.' | '!' | '~' + | ':' + | ' ' + | '&' + | '=' //For BASE64 IDs + | '%' //For URL encoded IDs | '*' | '\'' | '(' | ')' ; +UNDERSCORE : '_'; +HYPHEN : '-'; + ALPHANUM : ALPHA | DIGIT diff --git a/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Expression.g4 b/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Expression.g4 index bec8257904..b38c893b7c 100644 --- a/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Expression.g4 +++ b/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Expression.g4 @@ -1,17 +1,7 @@ /* - * Copyright (c) 2016 Yahoo! Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. See accompanying LICENSE file. + * Copyright 2016, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. */ grammar Expression; 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 e2435f0bae..005b91457c 100644 --- a/elide-core/src/main/java/com/yahoo/elide/Elide.java +++ b/elide-core/src/main/java/com/yahoo/elide/Elide.java @@ -1,68 +1,70 @@ /* - * Copyright 2016, Yahoo Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide; -import com.yahoo.elide.audit.AuditLogger; -import com.yahoo.elide.audit.Slf4jLogger; -import com.yahoo.elide.core.DataStore; -import com.yahoo.elide.core.DataStoreTransaction; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.HttpStatus; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.TransactionRegistry; +import com.yahoo.elide.core.audit.AuditLogger; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.inmemory.InMemoryDataStore; +import com.yahoo.elide.core.dictionary.Injector; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.exceptions.CustomErrorException; +import com.yahoo.elide.core.exceptions.ErrorMapper; +import com.yahoo.elide.core.exceptions.ErrorObjects; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; +import com.yahoo.elide.core.exceptions.HttpStatus; import com.yahoo.elide.core.exceptions.HttpStatusException; +import com.yahoo.elide.core.exceptions.InternalServerErrorException; import com.yahoo.elide.core.exceptions.InvalidURLException; import com.yahoo.elide.core.exceptions.JsonPatchExtensionException; import com.yahoo.elide.core.exceptions.TransactionException; -import com.yahoo.elide.core.filter.dialect.DefaultFilterDialect; -import com.yahoo.elide.core.filter.dialect.JoinFilterDialect; -import com.yahoo.elide.core.filter.dialect.MultipleFilterDialect; -import com.yahoo.elide.core.filter.dialect.SubqueryFilterDialect; -import com.yahoo.elide.extensions.JsonApiPatch; -import com.yahoo.elide.extensions.PatchRequestScope; -import com.yahoo.elide.generated.parsers.CoreLexer; -import com.yahoo.elide.generated.parsers.CoreParser; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.core.utils.coerce.converters.ElideTypeConverter; +import com.yahoo.elide.core.utils.coerce.converters.Serde; +import com.yahoo.elide.jsonapi.EntityProjectionMaker; import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.jsonapi.extensions.JsonApiPatch; +import com.yahoo.elide.jsonapi.extensions.PatchRequestScope; import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import com.yahoo.elide.parsers.DeleteVisitor; -import com.yahoo.elide.parsers.GetVisitor; -import com.yahoo.elide.parsers.PatchVisitor; -import com.yahoo.elide.parsers.PostVisitor; -import com.yahoo.elide.security.PermissionExecutor; -import com.yahoo.elide.security.SecurityMode; -import com.yahoo.elide.security.User; -import com.yahoo.elide.security.executors.ActivePermissionExecutor; - +import com.yahoo.elide.jsonapi.parser.BaseVisitor; +import com.yahoo.elide.jsonapi.parser.DeleteVisitor; +import com.yahoo.elide.jsonapi.parser.GetVisitor; +import com.yahoo.elide.jsonapi.parser.JsonApiParser; +import com.yahoo.elide.jsonapi.parser.PatchVisitor; +import com.yahoo.elide.jsonapi.parser.PostVisitor; +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; - -import org.antlr.v4.runtime.ANTLRInputStream; -import org.antlr.v4.runtime.BailErrorStrategy; -import org.antlr.v4.runtime.BaseErrorListener; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.RecognitionException; -import org.antlr.v4.runtime.Recognizer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; import org.antlr.v4.runtime.misc.ParseCancellationException; -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.io.File; import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; -import java.nio.file.Paths; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; -import java.util.function.Function; +import java.util.Map; +import java.util.Set; +import java.util.UUID; import java.util.function.Supplier; - +import java.util.stream.Collectors; +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.MultivaluedMap; /** @@ -70,604 +72,569 @@ */ @Slf4j public class Elide { - - - private final AuditLogger auditLogger; - private final DataStore dataStore; - private final EntityDictionary dictionary; - private final JsonApiMapper mapper; - private final Function permissionExecutor; - private final List joinFilterDialects; - private final List subqueryFilterDialects; - private final boolean useFilterExpressions; - - /** - * Instantiates a new Elide. - * - * @param auditLogger the audit logger - * @param dataStore the dataStore - * @param dictionary the dictionary - * @deprecated Since 2.1, use the {@link Elide.Builder} instead - */ - @Deprecated - public Elide(AuditLogger auditLogger, DataStore dataStore, EntityDictionary dictionary) { - this(auditLogger, dataStore, dictionary, new JsonApiMapper(dictionary)); - } - - /** - * Instantiates a new Elide. - * - * @param auditLogger the audit logger - * @param dataStore the dataStore - * @deprecated Since 2.1, use the {@link Elide.Builder} instead - */ - @Deprecated - public Elide(AuditLogger auditLogger, DataStore dataStore) { - this(auditLogger, dataStore, new EntityDictionary(new HashMap<>())); - } + public static final String JSONAPI_CONTENT_TYPE = "application/vnd.api+json"; + public static final String JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION = + "application/vnd.api+json; ext=jsonpatch"; + + @Getter private final ElideSettings elideSettings; + @Getter private final AuditLogger auditLogger; + @Getter private final DataStore dataStore; + @Getter private final JsonApiMapper mapper; + @Getter private final ErrorMapper errorMapper; + @Getter private final TransactionRegistry transactionRegistry; + @Getter private final ClassScanner scanner; + private boolean initialized = false; /** - * Instantiates a new Elide. + * Instantiates a new Elide instance. * - * @param auditLogger the audit logger - * @param dataStore the dataStore - * @param dictionary the dictionary - * @param mapper Serializer/Deserializer for JSON API - * @deprecated Since 2.1, use the {@link Elide.Builder} instead + * @param elideSettings Elide settings object. */ - @Deprecated - public Elide(AuditLogger auditLogger, DataStore dataStore, EntityDictionary dictionary, JsonApiMapper mapper) { - this( - auditLogger, - dataStore, - dictionary, - mapper, - ActivePermissionExecutor::new, - Collections.singletonList(new DefaultFilterDialect(dictionary)), - Collections.singletonList(new DefaultFilterDialect(dictionary)), - false - ); + public Elide( + ElideSettings elideSettings + ) { + this(elideSettings, new TransactionRegistry(), elideSettings.getDictionary().getScanner(), false); } /** - * Instantiates a new Elide. + * Instantiates a new Elide instance. * - * @param auditLogger the audit logger - * @param dataStore the dataStore - * @param dictionary the dictionary - * @param mapper Serializer/Deserializer for JSON API - * @param permissionExecutor Custom permission executor implementation + * @param elideSettings Elide settings object. + * @param transactionRegistry Global transaction state. */ - protected Elide(AuditLogger auditLogger, - DataStore dataStore, - EntityDictionary dictionary, - JsonApiMapper mapper, - Function permissionExecutor) { - this( - auditLogger, - dataStore, - dictionary, - mapper, - ActivePermissionExecutor::new, - Collections.singletonList(new DefaultFilterDialect(dictionary)), - Collections.singletonList(new DefaultFilterDialect(dictionary)), - false - ); + public Elide( + ElideSettings elideSettings, + TransactionRegistry transactionRegistry + ) { + this(elideSettings, transactionRegistry, elideSettings.getDictionary().getScanner(), false); } /** - * Instantiates a new Elide. + * Instantiates a new Elide instance. * - * @param auditLogger the audit logger - * @param dataStore the dataStore - * @param dictionary the dictionary - * @param mapper Serializer/Deserializer for JSON API - * @param permissionExecutor Custom permission executor implementation - * @param joinFilterDialects A list of filter parsers to use for filtering across types - * @param subqueryFilterDialects A list of filter parsers to use for filtering by type - * @param useFilterExpressions Whether or not to use Elide 3.0 filter expressions for DataStore interactions + * @param elideSettings Elide settings object. + * @param transactionRegistry Global transaction state. + * @param scanner Scans classes for Elide annotations. + * @param doScans Perform scans now. */ - protected Elide(AuditLogger auditLogger, - DataStore dataStore, - EntityDictionary dictionary, - JsonApiMapper mapper, - Function permissionExecutor, - List joinFilterDialects, - List subqueryFilterDialects, - boolean useFilterExpressions) { - this.auditLogger = auditLogger; - this.dataStore = dataStore; - this.dictionary = dictionary; - dataStore.populateEntityDictionary(dictionary); - this.mapper = mapper; - this.permissionExecutor = permissionExecutor; - this.joinFilterDialects = joinFilterDialects; - this.subqueryFilterDialects = subqueryFilterDialects; - this.useFilterExpressions = useFilterExpressions; + public Elide( + ElideSettings elideSettings, + TransactionRegistry transactionRegistry, + ClassScanner scanner, + boolean doScans + ) { + this.elideSettings = elideSettings; + this.scanner = scanner; + this.auditLogger = elideSettings.getAuditLogger(); + this.dataStore = new InMemoryDataStore(elideSettings.getDataStore()); + this.mapper = elideSettings.getMapper(); + this.errorMapper = elideSettings.getErrorMapper(); + this.transactionRegistry = transactionRegistry; + + if (doScans) { + doScans(); + } } /** - * Elide Builder for constructing an Elide instance. + * Scans & binds Elide models, scans for security check definitions, serde definitions, life cycle hooks + * and more. Any dependency injection required by objects found from scans must be performed prior to this call. */ - public static class Builder { - private final DataStore dataStore; - private AuditLogger auditLogger; - private JsonApiMapper jsonApiMapper; - private EntityDictionary entityDictionary = new EntityDictionary(new HashMap<>()); - private Function permissionExecutorFunction = ActivePermissionExecutor::new; - private List joinFilterDialects; - private List subqueryFilterDialects; - private boolean useFilterExpressions; - - /** - * A new builder used to generate Elide instances. Instantiates an {@link EntityDictionary} without - * providing a mapping of security checks. - * - * @param auditLogger the logger to use for audit annotations - * @param dataStore the datastore used to communicate with the persistence layer - * @deprecated 2.3 use {@link #Builder(DataStore)} - */ - public Builder(AuditLogger auditLogger, DataStore dataStore) { - this.auditLogger = auditLogger; - this.dataStore = dataStore; - this.jsonApiMapper = new JsonApiMapper(entityDictionary); - this.joinFilterDialects = new ArrayList<>(); - this.subqueryFilterDialects = new ArrayList<>(); + public void doScans() { + if (! initialized) { + elideSettings.getSerdes().forEach((type, serde) -> registerCustomSerde(type, serde, type.getSimpleName())); + registerCustomSerde(); - } + //Scan for security checks prior to populating data stores in case they need them. + elideSettings.getDictionary().scanForSecurityChecks(); - /** - * A new builder used to generate Elide instances. Instantiates an {@link EntityDictionary} without - * providing a mapping of security checks and uses the provided {@link Slf4jLogger} for audit. - * - * @param dataStore the datastore used to communicate with the persistence layer - */ - public Builder(DataStore dataStore) { - this.dataStore = dataStore; - this.auditLogger = new Slf4jLogger(); - this.jsonApiMapper = new JsonApiMapper(entityDictionary); - this.joinFilterDialects = new ArrayList<>(); - this.subqueryFilterDialects = new ArrayList<>(); + this.dataStore.populateEntityDictionary(elideSettings.getDictionary()); + initialized = true; } + } - public Elide build() { - if (joinFilterDialects.isEmpty()) { - joinFilterDialects.add(new DefaultFilterDialect(entityDictionary)); - } + protected void registerCustomSerde() { + Injector injector = elideSettings.getDictionary().getInjector(); + Set> classes = registerCustomSerdeScan(); - if (subqueryFilterDialects.isEmpty()) { - subqueryFilterDialects.add(new DefaultFilterDialect(entityDictionary)); + for (Class clazz : classes) { + if (!Serde.class.isAssignableFrom(clazz)) { + log.warn("Skipping Serde registration (not a Serde!): {}", clazz); + continue; } + Serde serde = (Serde) injector.instantiate(clazz); + injector.inject(serde); - return new Elide( - auditLogger, - dataStore, - entityDictionary, - jsonApiMapper, - permissionExecutorFunction, - joinFilterDialects, - subqueryFilterDialects, - useFilterExpressions); - } - - @Deprecated - public Builder auditLogger(final AuditLogger auditLogger) { - return withAuditLogger(auditLogger); - } - - @Deprecated - public Builder entityDictionary(final EntityDictionary entityDictionary) { - return withEntityDictionary(entityDictionary); - } - - @Deprecated - public Builder jsonApiMapper(final JsonApiMapper jsonApiMapper) { - return withJsonApiMapper(jsonApiMapper); - } - - @Deprecated - public Builder permissionExecutor(final Function permissionExecutorFunction) { - return withPermissionExecutor(permissionExecutorFunction); - } - - @Deprecated - public Builder permissionExecutor(final Class permissionExecutorClass) { - return withPermissionExecutor(permissionExecutorClass); - } + ElideTypeConverter converter = clazz.getAnnotation(ElideTypeConverter.class); + Class baseType = converter.type(); + registerCustomSerde(baseType, serde, converter.name()); - public Builder withAuditLogger(AuditLogger auditLogger) { - this.auditLogger = auditLogger; - return this; - } - - public Builder withEntityDictionary(EntityDictionary entityDictionary) { - this.entityDictionary = entityDictionary; - return this; - } - - public Builder withJsonApiMapper(JsonApiMapper jsonApiMapper) { - this.jsonApiMapper = jsonApiMapper; - return this; + for (Class type : converter.subTypes()) { + if (!baseType.isAssignableFrom(type)) { + throw new IllegalArgumentException("Mentioned type " + type + + " not subtype of " + baseType); + } + registerCustomSerde(type, serde, converter.name()); + } } + } - public Builder withPermissionExecutor(Function permissionExecutorFunction) { - this.permissionExecutorFunction = permissionExecutorFunction; - return this; - } + protected void registerCustomSerde(Class type, Serde serde, String name) { + log.info("Registering serde for type : {}", type); + CoerceUtil.register(type, serde); + registerCustomSerdeInObjectMapper(type, serde, name); + } - public Builder 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(); + protected void registerCustomSerdeInObjectMapper(Class type, Serde serde, String name) { + ObjectMapper objectMapper = mapper.getObjectMapper(); + objectMapper.registerModule(new SimpleModule(name) + .addSerializer(type, new JsonSerializer() { + @Override + public void serialize(Object obj, JsonGenerator jsonGenerator, + SerializerProvider serializerProvider) + throws IOException, JsonProcessingException { + jsonGenerator.writeObject(serde.serialize(obj)); } - } catch (IllegalAccessException | InstantiationException e) { - // Everything failed. Throw hands up, not sure how to proceed. - throw new RuntimeException(e); - } - }; - return this; - } + })); + } - public Builder withJoinFilterDialect(JoinFilterDialect dialect) { - useFilterExpressions = true; - joinFilterDialects.add(dialect); - return this; - } + protected Set> registerCustomSerdeScan() { + return scanner.getAnnotatedClasses(ElideTypeConverter.class); + } - public Builder withSubqueryFilterDialect(SubqueryFilterDialect dialect) { - useFilterExpressions = true; - subqueryFilterDialects.add(dialect); - return this; - } + /** + * Handle GET. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param path the path + * @param queryParams the query params + * @param opaqueUser the opaque user + * @param apiVersion the API version + * @return Elide response object + */ + public ElideResponse get(String baseUrlEndPoint, String path, MultivaluedMap queryParams, + User opaqueUser, String apiVersion) { + return get(baseUrlEndPoint, path, queryParams, opaqueUser, apiVersion, UUID.randomUUID()); } /** * Handle GET. * + * @param baseUrlEndPoint base URL with prefix endpoint * @param path the path * @param queryParams the query params * @param opaqueUser the opaque user - * @param securityMode only for test mode + * @param apiVersion the API version + * @param requestId the request ID * @return Elide response object - * @deprecated Since 2.1, instead use the {@link Elide.Builder} with an appropriate {@link PermissionExecutor} */ - @Deprecated - public ElideResponse get( - String path, - MultivaluedMap queryParams, - Object opaqueUser, - SecurityMode securityMode) { - - RequestScope requestScope = null; - boolean isVerbose = false; - try (DataStoreTransaction transaction = dataStore.beginReadTransaction()) { - final User user = transaction.accessUser(opaqueUser); - requestScope = new RequestScope( - path, - new JsonApiDocument(), - transaction, - user, - dictionary, - mapper, - auditLogger, - queryParams, - securityMode, - permissionExecutor, - new MultipleFilterDialect(joinFilterDialects, subqueryFilterDialects), - useFilterExpressions); + public ElideResponse get(String baseUrlEndPoint, String path, MultivaluedMap queryParams, + User opaqueUser, String apiVersion, UUID requestId) { + return get(baseUrlEndPoint, path, queryParams, Collections.emptyMap(), opaqueUser, apiVersion, requestId); + } - isVerbose = requestScope.getPermissionExecutor().isVerbose(); - GetVisitor visitor = new GetVisitor(requestScope); - Supplier> responder = visitor.visit(parse(path)); - transaction.preCommit(); - requestScope.getPermissionExecutor().executeCommitChecks(); - transaction.flush(); - ElideResponse response = buildResponse(responder.get()); - auditLogger.commit(); - transaction.commit(); - requestScope.runCommitTriggers(); - if (log.isTraceEnabled()) { - requestScope.getPermissionExecutor().printCheckStats(); + /** + * Handle GET. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param path the path + * @param queryParams the query params + * @param requestHeaders the request headers + * @param opaqueUser the opaque user + * @param apiVersion the API version + * @param requestId the request ID + * @return Elide response object + */ + public ElideResponse get(String baseUrlEndPoint, String path, MultivaluedMap queryParams, + Map> requestHeaders, User opaqueUser, String apiVersion, + UUID requestId) { + if (elideSettings.isStrictQueryParams()) { + try { + verifyQueryParams(queryParams); + } catch (BadRequestException e) { + return buildErrorResponse(e, false); } - return response; - } catch (ForbiddenAccessException e) { - log.debug("{}", e.getLoggedMessage()); - return buildErrorResponse(e, isVerbose); - } catch (HttpStatusException e) { - return buildErrorResponse(e, isVerbose); - } catch (IOException e) { - return buildErrorResponse(new TransactionException(e), isVerbose); - } catch (ParseCancellationException e) { - return buildErrorResponse(new InvalidURLException(e), isVerbose); - } finally { - auditLogger.clear(); } + return handleRequest(true, opaqueUser, dataStore::beginReadTransaction, requestId, (tx, user) -> { + JsonApiDocument jsonApiDoc = new JsonApiDocument(); + RequestScope requestScope = new RequestScope(baseUrlEndPoint, path, apiVersion, jsonApiDoc, + tx, user, queryParams, requestHeaders, requestId, elideSettings); + requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getDictionary(), + requestScope).parsePath(path)); + BaseVisitor visitor = new GetVisitor(requestScope); + return visit(path, requestScope, visitor); + }); } /** - * Handle GET. + * Handle POST. * + * @param baseUrlEndPoint base URL with prefix endpoint * @param path the path - * @param queryParams the query params + * @param jsonApiDocument the json api document * @param opaqueUser the opaque user + * @param apiVersion the API version * @return Elide response object */ - public ElideResponse get( - String path, - MultivaluedMap queryParams, - Object opaqueUser) { - return this.get(path, queryParams, opaqueUser, SecurityMode.SECURITY_ACTIVE); + public ElideResponse post(String baseUrlEndPoint, String path, String jsonApiDocument, + User opaqueUser, String apiVersion) { + return post(baseUrlEndPoint, path, jsonApiDocument, null, opaqueUser, apiVersion, UUID.randomUUID()); } /** * Handle POST. * + * @param baseUrlEndPoint base URL with prefix endpoint * @param path the path * @param jsonApiDocument the json api document + * @param queryParams the query params * @param opaqueUser the opaque user - * @param securityMode only for test mode + * @param apiVersion the API version + * @param requestId the request ID * @return Elide response object - * @deprecated Since 2.1, instead use the {@link Elide.Builder} with an appropriate {@link PermissionExecutor} */ - @Deprecated - public ElideResponse post( - String path, - String jsonApiDocument, - Object opaqueUser, - SecurityMode securityMode) { - RequestScope requestScope = null; - boolean isVerbose = false; - try (DataStoreTransaction transaction = dataStore.beginTransaction()) { - User user = transaction.accessUser(opaqueUser); - JsonApiDocument doc = mapper.readJsonApiDocument(jsonApiDocument); - requestScope = new RequestScope(path, doc, - transaction, - user, - dictionary, - mapper, - auditLogger, - securityMode, - permissionExecutor); - isVerbose = requestScope.getPermissionExecutor().isVerbose(); - PostVisitor visitor = new PostVisitor(requestScope); - Supplier> responder = visitor.visit(parse(path)); - transaction.preCommit(); - requestScope.getPermissionExecutor().executeCommitChecks(); - requestScope.saveObjects(); - transaction.flush(); - ElideResponse response = buildResponse(responder.get()); - auditLogger.commit(); - transaction.commit(); - requestScope.runCommitTriggers(); - if (log.isTraceEnabled()) { - requestScope.getPermissionExecutor().printCheckStats(); - } - return response; - } catch (ForbiddenAccessException e) { - log.debug("{}", e.getLoggedMessage()); - return buildErrorResponse(e, isVerbose); - } catch (HttpStatusException e) { - return buildErrorResponse(e, isVerbose); - } catch (IOException e) { - return buildErrorResponse(new TransactionException(e), isVerbose); - } catch (ParseCancellationException e) { - return buildErrorResponse(new InvalidURLException(e), isVerbose); - } finally { - auditLogger.clear(); - } + public ElideResponse post(String baseUrlEndPoint, String path, String jsonApiDocument, + MultivaluedMap queryParams, + User opaqueUser, String apiVersion, UUID requestId) { + return post(baseUrlEndPoint, path, jsonApiDocument, queryParams, Collections.emptyMap(), + opaqueUser, apiVersion, requestId); } /** * Handle POST. * + * @param baseUrlEndPoint base URL with prefix endpoint * @param path the path * @param jsonApiDocument the json api document + * @param queryParams the query params + * @param requestHeaders the request headers * @param opaqueUser the opaque user + * @param apiVersion the API version + * @param requestId the request ID * @return Elide response object */ - public ElideResponse post( - String path, - String jsonApiDocument, - Object opaqueUser) { - return this.post(path, jsonApiDocument, opaqueUser, SecurityMode.SECURITY_ACTIVE); + public ElideResponse post(String baseUrlEndPoint, String path, String jsonApiDocument, + MultivaluedMap queryParams, Map> requestHeaders, + User opaqueUser, String apiVersion, UUID requestId) { + return handleRequest(false, opaqueUser, dataStore::beginTransaction, requestId, (tx, user) -> { + JsonApiDocument jsonApiDoc = mapper.readJsonApiDocument(jsonApiDocument); + RequestScope requestScope = new RequestScope(baseUrlEndPoint, path, apiVersion, + jsonApiDoc, tx, user, queryParams, requestHeaders, requestId, elideSettings); + requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getDictionary(), + requestScope).parsePath(path)); + BaseVisitor visitor = new PostVisitor(requestScope); + return visit(path, requestScope, visitor); + }); } /** * Handle PATCH. * + * @param baseUrlEndPoint base URL with prefix endpoint * @param contentType the content type * @param accept the accept * @param path the path * @param jsonApiDocument the json api document * @param opaqueUser the opaque user - * @param securityMode only for test mode + * @param apiVersion the API version * @return Elide response object - * @deprecated Since 2.1, instead use the {@link Elide.Builder} with an appropriate {@link PermissionExecutor} */ - @Deprecated - public ElideResponse patch( - String contentType, - String accept, - String path, - String jsonApiDocument, - Object opaqueUser, - SecurityMode securityMode) { - RequestScope requestScope = null; - boolean isVerbose = false; - try (DataStoreTransaction transaction = dataStore.beginTransaction()) { - User user = transaction.accessUser(opaqueUser); - - Supplier> responder; - if (JsonApiPatch.isPatchExtension(contentType) && JsonApiPatch.isPatchExtension(accept)) { - // build Outer RequestScope to be used for each action - PatchRequestScope patchRequestScope = new PatchRequestScope(path, - transaction, user, dictionary, mapper, auditLogger, permissionExecutor); - requestScope = patchRequestScope; - isVerbose = requestScope.getPermissionExecutor().isVerbose(); - responder = JsonApiPatch.processJsonPatch(dataStore, path, jsonApiDocument, patchRequestScope); - } else { - JsonApiDocument doc = mapper.readJsonApiDocument(jsonApiDocument); - requestScope = new RequestScope(path, doc, transaction, user, dictionary, mapper, auditLogger, - securityMode, permissionExecutor); - isVerbose = requestScope.getPermissionExecutor().isVerbose(); - PatchVisitor visitor = new PatchVisitor(requestScope); - responder = visitor.visit(parse(path)); - } - transaction.preCommit(); - requestScope.getPermissionExecutor().executeCommitChecks(); - requestScope.saveObjects(); - transaction.flush(); - ElideResponse response = buildResponse(responder.get()); - auditLogger.commit(); - transaction.commit(); - requestScope.runCommitTriggers(); - if (log.isTraceEnabled()) { - requestScope.getPermissionExecutor().printCheckStats(); - } - return response; - } catch (ForbiddenAccessException e) { - log.debug("{}", e.getLoggedMessage()); - return buildErrorResponse(e, isVerbose); - } catch (JsonPatchExtensionException e) { - return buildResponse(e.getResponse()); - } catch (HttpStatusException e) { - return buildErrorResponse(e, isVerbose); - } catch (ParseCancellationException e) { - return buildErrorResponse(new InvalidURLException(e), isVerbose); - } catch (IOException e) { - return buildErrorResponse(new TransactionException(e), isVerbose); - } finally { - auditLogger.clear(); - } + public ElideResponse patch(String baseUrlEndPoint, String contentType, String accept, + String path, String jsonApiDocument, + User opaqueUser, String apiVersion) { + return patch(baseUrlEndPoint, contentType, accept, path, jsonApiDocument, + null, opaqueUser, apiVersion, UUID.randomUUID()); } /** * Handle PATCH. * + * @param baseUrlEndPoint base URL with prefix endpoint * @param contentType the content type * @param accept the accept * @param path the path * @param jsonApiDocument the json api document + * @param queryParams the query params * @param opaqueUser the opaque user + * @param apiVersion the API version + * @param requestId the request ID * @return Elide response object */ - public ElideResponse patch( - String contentType, - String accept, - String path, - String jsonApiDocument, - Object opaqueUser) { - return this.patch(contentType, accept, path, jsonApiDocument, opaqueUser, SecurityMode.SECURITY_ACTIVE); + public ElideResponse patch(String baseUrlEndPoint, String contentType, String accept, + String path, String jsonApiDocument, MultivaluedMap queryParams, + User opaqueUser, String apiVersion, UUID requestId) { + + return patch(baseUrlEndPoint, contentType, accept, path, jsonApiDocument, queryParams, + null, opaqueUser, apiVersion, requestId); + } + + /** + * Handle PATCH. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param contentType the content type + * @param accept the accept + * @param path the path + * @param jsonApiDocument the json api document + * @param queryParams the query params + * @param requestHeaders the request headers + * @param opaqueUser the opaque user + * @param apiVersion the API version + * @param requestId the request ID + * @return Elide response object + */ + public ElideResponse patch(String baseUrlEndPoint, String contentType, String accept, + String path, String jsonApiDocument, MultivaluedMap queryParams, + Map> requestHeaders, User opaqueUser, + String apiVersion, UUID requestId) { + + Handler handler; + if (JsonApiPatch.isPatchExtension(contentType) && JsonApiPatch.isPatchExtension(accept)) { + handler = (tx, user) -> { + PatchRequestScope requestScope = new PatchRequestScope(baseUrlEndPoint, path, apiVersion, tx, + user, requestId, queryParams, requestHeaders, elideSettings); + try { + Supplier> responder = + JsonApiPatch.processJsonPatch(dataStore, path, jsonApiDocument, requestScope); + return new HandlerResult(requestScope, responder); + } catch (RuntimeException e) { + return new HandlerResult(requestScope, e); + } + }; + } else { + handler = (tx, user) -> { + JsonApiDocument jsonApiDoc = mapper.readJsonApiDocument(jsonApiDocument); + + RequestScope requestScope = new RequestScope(baseUrlEndPoint, path, apiVersion, jsonApiDoc, + tx, user, queryParams, requestHeaders, requestId, elideSettings); + requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getDictionary(), + requestScope).parsePath(path)); + BaseVisitor visitor = new PatchVisitor(requestScope); + return visit(path, requestScope, visitor); + }; + } + + return handleRequest(false, opaqueUser, dataStore::beginTransaction, requestId, handler); + } + + /** + * Handle DELETE. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param path the path + * @param jsonApiDocument the json api document + * @param opaqueUser the opaque user + * @param apiVersion the API version + * @return Elide response object + */ + public ElideResponse delete(String baseUrlEndPoint, String path, String jsonApiDocument, + User opaqueUser, String apiVersion) { + return delete(baseUrlEndPoint, path, jsonApiDocument, null, opaqueUser, apiVersion, UUID.randomUUID()); + } + + /** + * Handle DELETE. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param path the path + * @param jsonApiDocument the json api document + * @param queryParams the query params + * @param opaqueUser the opaque user + * @param apiVersion the API version + * @param requestId the request ID + * @return Elide response object + */ + public ElideResponse delete(String baseUrlEndPoint, String path, String jsonApiDocument, + MultivaluedMap queryParams, + User opaqueUser, String apiVersion, UUID requestId) { + return delete(baseUrlEndPoint, path, jsonApiDocument, queryParams, Collections.emptyMap(), + opaqueUser, apiVersion, requestId); } /** * Handle DELETE. * + * @param baseUrlEndPoint base URL with prefix endpoint * @param path the path * @param jsonApiDocument the json api document + * @param queryParams the query params + * @param requestHeaders the request headers * @param opaqueUser the opaque user - * @param securityMode only for test mode + * @param apiVersion the API version + * @param requestId the request ID * @return Elide response object - * @deprecated Since 2.1, instead use the {@link Elide.Builder} with an appropriate {@link PermissionExecutor} */ - @Deprecated - public ElideResponse delete( - String path, - String jsonApiDocument, - Object opaqueUser, - SecurityMode securityMode) { - JsonApiDocument doc; - RequestScope requestScope = null; + public ElideResponse delete(String baseUrlEndPoint, String path, String jsonApiDocument, + MultivaluedMap queryParams, + Map> requestHeaders, + User opaqueUser, String apiVersion, UUID requestId) { + return handleRequest(false, opaqueUser, dataStore::beginTransaction, requestId, (tx, user) -> { + JsonApiDocument jsonApiDoc = StringUtils.isEmpty(jsonApiDocument) + ? new JsonApiDocument() + : mapper.readJsonApiDocument(jsonApiDocument); + RequestScope requestScope = new RequestScope(baseUrlEndPoint, path, apiVersion, jsonApiDoc, + tx, user, queryParams, requestHeaders, requestId, elideSettings); + requestScope.setEntityProjection(new EntityProjectionMaker(elideSettings.getDictionary(), + requestScope).parsePath(path)); + BaseVisitor visitor = new DeleteVisitor(requestScope); + return visit(path, requestScope, visitor); + }); + } + + public HandlerResult visit(String path, RequestScope requestScope, BaseVisitor visitor) { + try { + Supplier> responder = visitor.visit(JsonApiParser.parse(path)); + return new HandlerResult(requestScope, responder); + } catch (RuntimeException e) { + return new HandlerResult(requestScope, e); + } + } + + /** + * Handle JSON API requests. + * + * @param isReadOnly if the transaction is read only + * @param user the user object from the container + * @param transaction a transaction supplier + * @param requestId the Request ID + * @param handler a function that creates the request scope and request handler + * @return the response + */ + protected ElideResponse handleRequest(boolean isReadOnly, User user, + Supplier transaction, UUID requestId, + Handler handler) { boolean isVerbose = false; - try (DataStoreTransaction transaction = dataStore.beginTransaction()) { - User user = transaction.accessUser(opaqueUser); - if (jsonApiDocument != null && !jsonApiDocument.equals("")) { - doc = mapper.readJsonApiDocument(jsonApiDocument); - } else { - doc = new JsonApiDocument(); - } - requestScope = new RequestScope( - path, doc, transaction, user, dictionary, mapper, auditLogger, securityMode, permissionExecutor); + try (DataStoreTransaction tx = transaction.get()) { + transactionRegistry.addRunningTransaction(requestId, tx); + HandlerResult result = handler.handle(tx, user); + RequestScope requestScope = result.getRequestScope(); isVerbose = requestScope.getPermissionExecutor().isVerbose(); - DeleteVisitor visitor = new DeleteVisitor(requestScope); - Supplier> responder = visitor.visit(parse(path)); - transaction.preCommit(); + Supplier> responder = result.getResponder(); + tx.preCommit(requestScope); + requestScope.runQueuedPreSecurityTriggers(); requestScope.getPermissionExecutor().executeCommitChecks(); - requestScope.saveObjects(); - transaction.flush(); + requestScope.runQueuedPreFlushTriggers(); + if (!isReadOnly) { + requestScope.saveOrCreateObjects(); + } + tx.flush(requestScope); + + requestScope.runQueuedPreCommitTriggers(); + ElideResponse response = buildResponse(responder.get()); + auditLogger.commit(); - transaction.commit(); - requestScope.runCommitTriggers(); + tx.commit(requestScope); + requestScope.runQueuedPostCommitTriggers(); + if (log.isTraceEnabled()) { - requestScope.getPermissionExecutor().printCheckStats(); + requestScope.getPermissionExecutor().logCheckStats(); } + return response; - } catch (ForbiddenAccessException e) { - log.debug("{}", e.getLoggedMessage()); - return buildErrorResponse(e, isVerbose); - } catch (HttpStatusException e) { - return buildErrorResponse(e, isVerbose); + } catch (JacksonException e) { + String message = (e.getLocation() != null && e.getLocation().getSourceRef() != null) + ? e.getMessage() //This will leak Java class info if the location isn't known. + : e.getOriginalMessage(); + + return buildErrorResponse(new BadRequestException(message), isVerbose); } catch (IOException e) { + log.error("IO Exception uncaught by Elide", e); return buildErrorResponse(new TransactionException(e), isVerbose); - } catch (ParseCancellationException e) { - return buildErrorResponse(new InvalidURLException(e), isVerbose); + } catch (RuntimeException e) { + return handleRuntimeException(e, isVerbose); } finally { + transactionRegistry.removeRunningTransaction(requestId); auditLogger.clear(); } } - /** - * Handle DELETE. - * - * @param path the path - * @param jsonApiDocument the json api document - * @param opaqueUser the opaque user - * @return Elide response object - */ - public ElideResponse delete( - String path, - String jsonApiDocument, - Object opaqueUser) { - return this.delete(path, jsonApiDocument, opaqueUser, SecurityMode.SECURITY_ACTIVE); + protected ElideResponse buildErrorResponse(HttpStatusException error, boolean isVerbose) { + if (error instanceof InternalServerErrorException) { + log.error("Internal Server Error", error); + } + + return buildResponse(isVerbose ? error.getVerboseErrorResponse() + : error.getErrorResponse()); } - /** - * Compile request to AST. - * - * @param path request - * @return AST parse tree - */ - public static ParseTree parse(String path) { - path = Paths.get(path).normalize().toString().replace(File.separatorChar, '/'); - if (path.startsWith("/")) { - path = path.substring(1); + private ElideResponse handleRuntimeException(RuntimeException error, boolean isVerbose) { + CustomErrorException mappedException = mapError(error); + + if (mappedException != null) { + return buildErrorResponse(mappedException, isVerbose); + } + + if (error instanceof WebApplicationException) { + throw error; } - ANTLRInputStream is = new ANTLRInputStream(path); - CoreLexer lexer = new CoreLexer(is); - lexer.removeErrorListeners(); - lexer.addErrorListener(new BaseErrorListener() { - @Override - public void syntaxError(Recognizer recognizer, Object offendingSymbol, int line, - int charPositionInLine, String msg, RecognitionException e) { - throw new ParseCancellationException(msg, e); + + if (error instanceof ForbiddenAccessException) { + ForbiddenAccessException e = (ForbiddenAccessException) error; + if (log.isDebugEnabled()) { + log.debug("{}", e.getLoggedMessage()); } - }); - CoreParser parser = new CoreParser(new CommonTokenStream(lexer)); - parser.setErrorHandler(new BailErrorStrategy()); - return parser.start(); + return buildErrorResponse(e, isVerbose); + } + + if (error instanceof JsonPatchExtensionException) { + JsonPatchExtensionException e = (JsonPatchExtensionException) error; + log.debug("JSON patch extension exception caught", e); + return buildErrorResponse(e, isVerbose); + } + + if (error instanceof HttpStatusException) { + HttpStatusException e = (HttpStatusException) error; + log.debug("Caught HTTP status exception", e); + return buildErrorResponse(e, isVerbose); + } + + if (error instanceof ParseCancellationException) { + ParseCancellationException e = (ParseCancellationException) error; + log.debug("Parse cancellation exception uncaught by Elide (i.e. invalid URL)", e); + return buildErrorResponse(new InvalidURLException(e), isVerbose); + } + + if (error instanceof ConstraintViolationException) { + ConstraintViolationException e = (ConstraintViolationException) error; + log.debug("Constraint violation exception caught", e); + String message = "Constraint violation"; + final ErrorObjects.ErrorObjectsBuilder errorObjectsBuilder = ErrorObjects.builder(); + for (ConstraintViolation constraintViolation : e.getConstraintViolations()) { + errorObjectsBuilder.addError() + .withDetail(constraintViolation.getMessage()); + final String propertyPathString = constraintViolation.getPropertyPath().toString(); + if (!propertyPathString.isEmpty()) { + Map source = new HashMap<>(1); + source.put("property", propertyPathString); + errorObjectsBuilder.with("source", source); + } + } + return buildErrorResponse( + new CustomErrorException(HttpStatus.SC_BAD_REQUEST, message, errorObjectsBuilder.build()), + isVerbose + ); + } + + log.error("Error or exception uncaught by Elide", error); + throw new RuntimeException(error); } - protected ElideResponse buildErrorResponse(HttpStatusException error, boolean isVerbose) { - return buildResponse(isVerbose ? error.getVerboseErrorResponse() : error.getErrorResponse()); + public CustomErrorException mapError(RuntimeException error) { + if (errorMapper != null) { + log.trace("Attempting to map unknown exception of type {}", error.getClass()); + CustomErrorException customizedError = errorMapper.map(error); + + if (customizedError != null) { + log.debug("Successfully mapped exception from type {} to {}", + error.getClass(), customizedError.getClass()); + return customizedError; + } else { + log.debug("No error mapping present for {}", error.getClass()); + } + } + + return null; } protected ElideResponse buildResponse(Pair response) { @@ -680,4 +647,66 @@ protected ElideResponse buildResponse(Pair response) { return new ElideResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, e.toString()); } } + + private void verifyQueryParams(MultivaluedMap queryParams) { + String undefinedKeys = queryParams.keySet() + .stream() + .filter(Elide::notAValidKey) + .collect(Collectors.joining(", ")); + + if (!undefinedKeys.isEmpty()) { + throw new BadRequestException("Found undefined keys in request: " + undefinedKeys); + } + } + + private static boolean notAValidKey(String key) { + boolean validKey = key.equals("sort") + || key.startsWith("filter") + || (key.startsWith("fields[") && key.endsWith("]")) + || key.startsWith("page[") + || key.equals(EntityProjectionMaker.INCLUDE); + return !validKey; + } + + /** + * A function that sets up the request handling objects. + * + * @param the request's transaction + * @param the request's user + * @param the request handling objects + */ + @FunctionalInterface + public interface Handler { + HandlerResult handle(DataStoreTransaction a, User b) throws IOException; + } + + /** + * A wrapper to return multiple values, less verbose than Pair. + */ + protected static class HandlerResult { + protected RequestScope requestScope; + protected Supplier> result; + protected RuntimeException cause; + + protected HandlerResult(RequestScope requestScope, Supplier> result) { + this.requestScope = requestScope; + this.result = result; + } + + public HandlerResult(RequestScope requestScope, RuntimeException cause) { + this.requestScope = requestScope; + this.cause = cause; + } + + public Supplier> getResponder() { + if (cause != null) { + throw cause; + } + return result; + } + + public RequestScope getRequestScope() { + return requestScope; + } + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/ElideResponse.java b/elide-core/src/main/java/com/yahoo/elide/ElideResponse.java index 2ff96c1449..795b1c1f32 100644 --- a/elide-core/src/main/java/com/yahoo/elide/ElideResponse.java +++ b/elide-core/src/main/java/com/yahoo/elide/ElideResponse.java @@ -5,11 +5,13 @@ */ package com.yahoo.elide; +import lombok.Builder; import lombok.Getter; /** * Elide response object. */ +@Builder public class ElideResponse { @Getter private final int responseCode; @Getter private final String body; diff --git a/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java b/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java new file mode 100644 index 0000000000..b9d4c0b11b --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java @@ -0,0 +1,52 @@ +/* + * Copyright 2017, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.audit.AuditLogger; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.ErrorMapper; +import com.yahoo.elide.core.filter.dialect.graphql.FilterDialect; +import com.yahoo.elide.core.filter.dialect.jsonapi.JoinFilterDialect; +import com.yahoo.elide.core.filter.dialect.jsonapi.SubqueryFilterDialect; +import com.yahoo.elide.core.security.PermissionExecutor; +import com.yahoo.elide.core.utils.coerce.converters.Serde; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.jsonapi.links.JSONApiLinks; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Object containing general Elide settings passed to RequestScope. + */ +@AllArgsConstructor +public class ElideSettings { + @Getter private final AuditLogger auditLogger; + @Getter private final DataStore dataStore; + @Getter private final EntityDictionary dictionary; + @Getter private final JsonApiMapper mapper; + @Getter private final ErrorMapper errorMapper; + @Getter private final Function permissionExecutor; + @Getter private final List joinFilterDialects; + @Getter private final List subqueryFilterDialects; + @Getter private final FilterDialect graphqlDialect; + @Getter private final JSONApiLinks jsonApiLinks; + @Getter private final int defaultMaxPageSize; + @Getter private final int defaultPageSize; + @Getter private final int updateStatusCode; + @Getter private final Map serdes; + @Getter private final boolean enableJsonLinks; + @Getter private final boolean strictQueryParams; + @Getter private final String baseUrl; + @Getter private final String jsonApiPath; + @Getter private final String graphQLApiPath; + @Getter private final String exportApiPath; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java b/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java new file mode 100644 index 0000000000..a436f043a7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java @@ -0,0 +1,253 @@ +/* + * Copyright 2017, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.audit.AuditLogger; +import com.yahoo.elide.core.audit.Slf4jLogger; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.ErrorMapper; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.filter.dialect.graphql.FilterDialect; +import com.yahoo.elide.core.filter.dialect.jsonapi.DefaultFilterDialect; +import com.yahoo.elide.core.filter.dialect.jsonapi.JoinFilterDialect; +import com.yahoo.elide.core.filter.dialect.jsonapi.SubqueryFilterDialect; +import com.yahoo.elide.core.pagination.PaginationImpl; +import com.yahoo.elide.core.security.PermissionExecutor; +import com.yahoo.elide.core.security.executors.ActivePermissionExecutor; +import com.yahoo.elide.core.security.executors.VerbosePermissionExecutor; +import com.yahoo.elide.core.utils.coerce.converters.EpochToDateConverter; +import com.yahoo.elide.core.utils.coerce.converters.ISO8601DateSerde; +import com.yahoo.elide.core.utils.coerce.converters.InstantSerde; +import com.yahoo.elide.core.utils.coerce.converters.OffsetDateTimeSerde; +import com.yahoo.elide.core.utils.coerce.converters.Serde; +import com.yahoo.elide.core.utils.coerce.converters.TimeZoneSerde; +import com.yahoo.elide.core.utils.coerce.converters.URLSerde; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.jsonapi.links.JSONApiLinks; + +import java.net.URL; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.function.Function; + +/** + * Builder for ElideSettings. + */ +public class ElideSettingsBuilder { + private final DataStore dataStore; + private AuditLogger auditLogger; + private JsonApiMapper jsonApiMapper; + private ErrorMapper errorMapper; + private EntityDictionary entityDictionary; + private Function permissionExecutorFunction = ActivePermissionExecutor::new; + private List joinFilterDialects; + private List subqueryFilterDialects; + private FilterDialect graphqlFilterDialect; + private JSONApiLinks jsonApiLinks; + private Map serdes; + private int defaultMaxPageSize = PaginationImpl.MAX_PAGE_LIMIT; + private int defaultPageSize = PaginationImpl.DEFAULT_PAGE_LIMIT; + private int updateStatusCode; + private boolean enableJsonLinks; + private boolean strictQueryParams = true; + private String baseUrl = ""; + private String jsonApiPath; + private String graphQLApiPath; + private String exportApiPath; + + /** + * A new builder used to generate Elide instances. Instantiates an {@link EntityDictionary} without + * providing a mapping of security checks and uses the provided {@link Slf4jLogger} for audit. + * + * @param dataStore the datastore used to communicate with the persistence layer + */ + public ElideSettingsBuilder(DataStore dataStore) { + this.dataStore = dataStore; + this.auditLogger = new Slf4jLogger(); + this.jsonApiMapper = new JsonApiMapper(); + this.joinFilterDialects = new ArrayList<>(); + this.subqueryFilterDialects = new ArrayList<>(); + updateStatusCode = HttpStatus.SC_NO_CONTENT; + this.serdes = new LinkedHashMap<>(); + this.enableJsonLinks = false; + + //By default, Elide supports epoch based dates. + this.withEpochDates(); + this.withDefaultSerdes(); + } + + public ElideSettings build() { + if (joinFilterDialects.isEmpty()) { + joinFilterDialects.add(new DefaultFilterDialect(entityDictionary)); + joinFilterDialects.add(RSQLFilterDialect.builder().dictionary(entityDictionary).build()); + } + + if (subqueryFilterDialects.isEmpty()) { + subqueryFilterDialects.add(new DefaultFilterDialect(entityDictionary)); + subqueryFilterDialects.add(RSQLFilterDialect.builder().dictionary(entityDictionary).build()); + } + + if (graphqlFilterDialect == null) { + graphqlFilterDialect = RSQLFilterDialect.builder().dictionary(entityDictionary).build(); + } + + if (entityDictionary == null) { + throw new IllegalStateException("EntityDictionary must be set in ElideSettings."); + } + + return new ElideSettings( + auditLogger, + dataStore, + entityDictionary, + jsonApiMapper, + errorMapper, + permissionExecutorFunction, + joinFilterDialects, + subqueryFilterDialects, + graphqlFilterDialect, + jsonApiLinks, + defaultMaxPageSize, + defaultPageSize, + updateStatusCode, + serdes, + enableJsonLinks, + strictQueryParams, + baseUrl, + jsonApiPath, + graphQLApiPath, + exportApiPath); + } + + public ElideSettingsBuilder withAuditLogger(AuditLogger auditLogger) { + this.auditLogger = auditLogger; + return this; + } + + public ElideSettingsBuilder withEntityDictionary(EntityDictionary entityDictionary) { + this.entityDictionary = entityDictionary; + return this; + } + + public ElideSettingsBuilder withJsonApiMapper(JsonApiMapper jsonApiMapper) { + this.jsonApiMapper = jsonApiMapper; + return this; + } + + + public ElideSettingsBuilder withErrorMapper(ErrorMapper errorMapper) { + this.errorMapper = errorMapper; + return this; + } + + public ElideSettingsBuilder withJoinFilterDialect(JoinFilterDialect dialect) { + joinFilterDialects.add(dialect); + return this; + } + + public ElideSettingsBuilder withSubqueryFilterDialect(SubqueryFilterDialect dialect) { + subqueryFilterDialects.add(dialect); + return this; + } + + public ElideSettingsBuilder withDefaultMaxPageSize(int maxPageSize) { + defaultMaxPageSize = maxPageSize; + return this; + } + + public ElideSettingsBuilder withDefaultPageSize(int pageSize) { + defaultPageSize = pageSize; + return this; + } + + public ElideSettingsBuilder withUpdate200Status() { + updateStatusCode = HttpStatus.SC_OK; + return this; + } + + public ElideSettingsBuilder withUpdate204Status() { + updateStatusCode = HttpStatus.SC_NO_CONTENT; + return this; + } + + /** + * Sets the base URL that will be returned in URLs generated by Elide. + * @param baseUrl The base URL that clients use in queries. + * @return the settings builder. + */ + public ElideSettingsBuilder withBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public ElideSettingsBuilder withGraphQLDialect(FilterDialect dialect) { + graphqlFilterDialect = dialect; + return this; + } + + public ElideSettingsBuilder withVerboseErrors() { + permissionExecutorFunction = VerbosePermissionExecutor::new; + return this; + } + + public ElideSettingsBuilder withISO8601Dates(String dateFormat, TimeZone tz) { + serdes.put(Date.class, new ISO8601DateSerde(dateFormat, tz)); + serdes.put(java.sql.Date.class, new ISO8601DateSerde(dateFormat, tz, java.sql.Date.class)); + serdes.put(java.sql.Time.class, new ISO8601DateSerde(dateFormat, tz, java.sql.Time.class)); + serdes.put(java.sql.Timestamp.class, new ISO8601DateSerde(dateFormat, tz, java.sql.Timestamp.class)); + return this; + } + + public ElideSettingsBuilder withEpochDates() { + serdes.put(Date.class, new EpochToDateConverter<>(Date.class)); + serdes.put(java.sql.Date.class, new EpochToDateConverter<>(java.sql.Date.class)); + serdes.put(java.sql.Time.class, new EpochToDateConverter<>(java.sql.Time.class)); + serdes.put(java.sql.Timestamp.class, new EpochToDateConverter<>(java.sql.Timestamp.class)); + return this; + } + + public ElideSettingsBuilder withDefaultSerdes() { + serdes.put(Instant.class, new InstantSerde()); + serdes.put(OffsetDateTime.class, new OffsetDateTimeSerde()); + serdes.put(TimeZone.class, new TimeZoneSerde()); + serdes.put(URL.class, new URLSerde()); + return this; + } + + public ElideSettingsBuilder withJSONApiLinks(JSONApiLinks links) { + this.enableJsonLinks = true; + this.jsonApiLinks = links; + return this; + } + + public ElideSettingsBuilder withJsonApiPath(String jsonApiPath) { + this.jsonApiPath = jsonApiPath; + return this; + } + + public ElideSettingsBuilder withGraphQLApiPath(String graphQLApiPath) { + this.graphQLApiPath = graphQLApiPath; + return this; + } + + public ElideSettingsBuilder withExportApiPath(String exportApiPath) { + this.exportApiPath = exportApiPath; + return this; + } + + public ElideSettingsBuilder withStrictQueryParams(boolean enabled) { + this.strictQueryParams = enabled; + return this; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/RefreshableElide.java b/elide-core/src/main/java/com/yahoo/elide/RefreshableElide.java new file mode 100644 index 0000000000..0b164acf2f --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/RefreshableElide.java @@ -0,0 +1,22 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide; + +import lombok.Getter; + +/** + * Wraps an Elide instance that can be hot reloaded at runtime. This class is restricted to + * a single access method (getElide) to eliminate state issues across reloads. + */ +public class RefreshableElide { + @Getter + private Elide elide; + + public RefreshableElide(Elide elide) { + this.elide = elide; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/ApiVersion.java b/elide-core/src/main/java/com/yahoo/elide/annotation/ApiVersion.java new file mode 100644 index 0000000000..99f04a7f9a --- /dev/null +++ b/elide-core/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/Audit.java b/elide-core/src/main/java/com/yahoo/elide/annotation/Audit.java similarity index 87% rename from elide-annotations/src/main/java/com/yahoo/elide/annotation/Audit.java rename to elide-core/src/main/java/com/yahoo/elide/annotation/Audit.java index 0cba01cafd..9c832c32dd 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Audit.java +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/Audit.java @@ -5,17 +5,17 @@ */ package com.yahoo.elide.annotation; -import java.lang.annotation.Inherited; -import java.lang.annotation.Repeatable; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.ElementType.METHOD; 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.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + /** * Audit configuration annotation. */ @@ -50,13 +50,6 @@ enum Action { */ Action[] action() default {Action.CREATE, Action.UPDATE, Action.DELETE}; - /** - * Regular expression applied to the path segments of the URI. The audit only occurs if the expression - * is empty or it matches the request URI - * @return the string (default = "") - */ - String path() default ""; - /** * Operation code. * diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Audits.java b/elide-core/src/main/java/com/yahoo/elide/annotation/Audits.java similarity index 99% rename from elide-annotations/src/main/java/com/yahoo/elide/annotation/Audits.java rename to elide-core/src/main/java/com/yahoo/elide/annotation/Audits.java index fe0a443838..cce73ca16b 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Audits.java +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/Audits.java @@ -5,16 +5,15 @@ */ package com.yahoo.elide.annotation; - -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.ElementType.METHOD; 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; + /** * Audit configuration annotation repeatable. */ diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/ComputedAttribute.java b/elide-core/src/main/java/com/yahoo/elide/annotation/ComputedAttribute.java similarity index 100% rename from elide-annotations/src/main/java/com/yahoo/elide/annotation/ComputedAttribute.java rename to elide-core/src/main/java/com/yahoo/elide/annotation/ComputedAttribute.java index c12fba04aa..95be8043c4 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/ComputedAttribute.java +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/ComputedAttribute.java @@ -5,13 +5,13 @@ */ package com.yahoo.elide.annotation; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.ElementType.METHOD; import static java.lang.annotation.RetentionPolicy.RUNTIME; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + /** * Marks a method or field as a computed attribute that should be exposed via Elide regardless of whether or * not it is marked as Transient. diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/ComputedRelationship.java b/elide-core/src/main/java/com/yahoo/elide/annotation/ComputedRelationship.java similarity index 100% rename from elide-annotations/src/main/java/com/yahoo/elide/annotation/ComputedRelationship.java rename to elide-core/src/main/java/com/yahoo/elide/annotation/ComputedRelationship.java diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/CreatePermission.java b/elide-core/src/main/java/com/yahoo/elide/annotation/CreatePermission.java new file mode 100644 index 0000000000..7416900261 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/CreatePermission.java @@ -0,0 +1,37 @@ +/* + * 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.FIELD; +import static java.lang.annotation.ElementType.METHOD; +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; + +/** + * Assign custom Create permission checks. + */ +@Target({TYPE, PACKAGE, METHOD, FIELD}) +@Retention(RUNTIME) +@Inherited +public @interface CreatePermission { + + /** + * An expression of checks that will be parsed via ANTLR. For example: + * {@code @CreatePermission(expression="Prefab.Role.All")} or + * {@code @CreatePermission(expression="Prefab.Role.All and Prefab.Role.UpdateOnCreate")} + * + * All of {@linkplain com.yahoo.elide.core.security.checks.prefab the built-in checks} are name-spaced as + * {@code Prefab.CHECK} without the {@code Check} suffix + * + * @return the expression string to be parsed + */ + String expression() default ""; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/DeletePermission.java b/elide-core/src/main/java/com/yahoo/elide/annotation/DeletePermission.java new file mode 100644 index 0000000000..2fcbbb8e7f --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/DeletePermission.java @@ -0,0 +1,35 @@ +/* + * 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; + +/** + * Assign custom Delete permission checks. + */ +@Target({TYPE, PACKAGE}) +@Retention(RUNTIME) +@Inherited +public @interface DeletePermission { + + /** + * An expression of checks that will be parsed via ANTLR. For example: + * {@code @DeletePermission(expression="Prefab.Role.All")} or + * {@code @DeletePermission(expression="Prefab.Role.All and Prefab.Role.UpdateOnCreate")} + * + * All of {@linkplain com.yahoo.elide.core.security.checks.prefab the built-in checks} are name-spaced as + * {@code Prefab.CHECK} without the {@code Check} suffix + * + * @return the expression string to be parsed + */ + String expression() default ""; +} diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Exclude.java b/elide-core/src/main/java/com/yahoo/elide/annotation/Exclude.java similarity index 93% rename from elide-annotations/src/main/java/com/yahoo/elide/annotation/Exclude.java rename to elide-core/src/main/java/com/yahoo/elide/annotation/Exclude.java index 8e4ea3bbc2..60dcce6e1e 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Exclude.java +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/Exclude.java @@ -5,21 +5,19 @@ */ package com.yahoo.elide.annotation; -import java.lang.annotation.Inherited; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; - import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.ElementType.METHOD; 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.Retention; +import java.lang.annotation.Target; + /** * Restricts access to given entity or entity attribute/relationship. */ @Target({METHOD, FIELD, TYPE, PACKAGE}) @Retention(RUNTIME) -@Inherited public @interface Exclude { } diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/FilterExpressionPath.java b/elide-core/src/main/java/com/yahoo/elide/annotation/FilterExpressionPath.java new file mode 100644 index 0000000000..6f924071ec --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/FilterExpressionPath.java @@ -0,0 +1,34 @@ +/* + * Copyright 2018, 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.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Builds multi-bean properties path for FilterExpressionCheck. + *
+ * 
+ *  @Transient
+ *  @ComputedRelationship
+ *  @OneToOne
+ *  @FilterExpressionPath("publisher.editor")
+ *  @ReadPermission(expression = "Field path editor check")
+ *  public Author getEditor() {
+ *    return getPublisher().getEditor();
+ *  }
+ * 
+ * 
+ */ +@Target({ METHOD, FIELD }) +@Retention(RUNTIME) +public @interface FilterExpressionPath { + String value(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/Include.java b/elide-core/src/main/java/com/yahoo/elide/annotation/Include.java new file mode 100644 index 0000000000..2910271f69 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/Include.java @@ -0,0 +1,48 @@ +/* + * Copyright 2015, 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.Retention; +import java.lang.annotation.Target; + +/** + * Allows access to given entity. + */ +@Target({TYPE, PACKAGE}) +@Retention(RUNTIME) +public @interface Include { + + /** + * (Optional) Whether or not the entity can be accessed at the root URL path (i.e. /company). + * @return the boolean + */ + boolean rootLevel() default true; + + /** + * When annotating a type, the name of the model. When unset, the model name defaults to the + * simple name of the entity class. + * + * When annotation a package, the prefix to apply (prefix_modelName) to apply to each model in the package. + * @return the string + */ + String name() default ""; + + /** + * The model or package description. + * @return the string + */ + String description() default ""; + + /** + * The model or package friendly name (for display). + * @return the string + */ + String friendlyName() default ""; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java b/elide-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java new file mode 100644 index 0000000000..b1e0931131 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java @@ -0,0 +1,69 @@ +/* + * 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.core.lifecycle.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 { + + Operation [] ALL_OPERATIONS = { + Operation.CREATE, + Operation.UPDATE, + Operation.DELETE + }; + + enum Operation { + CREATE, + UPDATE, + DELETE + }; + + enum TransactionPhase { + PRESECURITY, + PREFLUSH, + 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-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBindings.java b/elide-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBindings.java new file mode 100644 index 0000000000..5a030a0b0b --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBindings.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.annotation; + + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A group of repeatable LifeCycleHookBinding annotations. + */ +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface LifeCycleHookBindings { + LifeCycleHookBinding[] value(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/MappedInterface.java b/elide-core/src/main/java/com/yahoo/elide/annotation/MappedInterface.java new file mode 100644 index 0000000000..a136a53e5a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/MappedInterface.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017, 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.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Used to annotate interfaces that represent mapped entities. + * Used for returning non related entities, for examaple from HQL or @Any mapped methods. + */ +@Target({TYPE}) +@Retention(RUNTIME) +public @interface MappedInterface { +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/NonTransferable.java b/elide-core/src/main/java/com/yahoo/elide/annotation/NonTransferable.java new file mode 100644 index 0000000000..55d72065d7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/NonTransferable.java @@ -0,0 +1,38 @@ +/* + * 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; + + /** + * Non-strict allows nested object hierarchies of non-transferables that are created in more than one + * client request. A non-transferable, A, can have a relationship updated post creation IF: + * - Another non-transferable, B, is being added to it. + * - A is the request lineage of B. For example, /A/1/B is the request path. + */ + boolean strict() default false; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/OnCreatePostCommit.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnCreatePostCommit.java new file mode 100644 index 0000000000..cfb3aa00f0 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/OnCreatePostCommit.java @@ -0,0 +1,33 @@ +/* + * 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. + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +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-core/src/main/java/com/yahoo/elide/annotation/OnCreatePreCommit.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnCreatePreCommit.java new file mode 100644 index 0000000000..193116d2a7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/OnCreatePreCommit.java @@ -0,0 +1,33 @@ +/* + * 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. + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +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-core/src/main/java/com/yahoo/elide/annotation/OnCreatePreSecurity.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnCreatePreSecurity.java new file mode 100644 index 0000000000..64b53128dd --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/OnCreatePreSecurity.java @@ -0,0 +1,31 @@ +/* + * 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. + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +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-core/src/main/java/com/yahoo/elide/annotation/OnDeletePostCommit.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnDeletePostCommit.java new file mode 100644 index 0000000000..e807561682 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/OnDeletePostCommit.java @@ -0,0 +1,25 @@ +/* + * 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. + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +public @interface OnDeletePostCommit { + +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/OnDeletePreCommit.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnDeletePreCommit.java new file mode 100644 index 0000000000..e47f92fb36 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/OnDeletePreCommit.java @@ -0,0 +1,25 @@ +/* + * 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. + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +public @interface OnDeletePreCommit { + +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/OnDeletePreSecurity.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnDeletePreSecurity.java new file mode 100644 index 0000000000..983a3fd5af --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/OnDeletePreSecurity.java @@ -0,0 +1,23 @@ +/* + * 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 Delete trigger annotation. + * + * The invoked function takes a RequestScope as parameter. + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +public @interface OnDeletePreSecurity { + +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/OnUpdatePostCommit.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnUpdatePostCommit.java new file mode 100644 index 0000000000..b19051b187 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/OnUpdatePostCommit.java @@ -0,0 +1,33 @@ +/* + * 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. + * + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +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-core/src/main/java/com/yahoo/elide/annotation/OnUpdatePreCommit.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnUpdatePreCommit.java new file mode 100644 index 0000000000..ebb521a76e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/OnUpdatePreCommit.java @@ -0,0 +1,33 @@ +/* + * 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. + * + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +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-core/src/main/java/com/yahoo/elide/annotation/OnUpdatePreSecurity.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnUpdatePreSecurity.java new file mode 100644 index 0000000000..9c1b1d4f0a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/OnUpdatePreSecurity.java @@ -0,0 +1,31 @@ +/* + * 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. + * + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Deprecated +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-core/src/main/java/com/yahoo/elide/annotation/PATCH.java b/elide-core/src/main/java/com/yahoo/elide/annotation/PATCH.java index c8469dc3db..6c14470923 100644 --- a/elide-core/src/main/java/com/yahoo/elide/annotation/PATCH.java +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/PATCH.java @@ -5,14 +5,13 @@ */ package com.yahoo.elide.annotation; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + import java.lang.annotation.Retention; import java.lang.annotation.Target; - import javax.ws.rs.HttpMethod; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - /** * Indicates that the annotated method responds to HTTP PATCH requests. */ diff --git a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Paginate.java b/elide-core/src/main/java/com/yahoo/elide/annotation/Paginate.java similarity index 94% rename from elide-annotations/src/main/java/com/yahoo/elide/annotation/Paginate.java rename to elide-core/src/main/java/com/yahoo/elide/annotation/Paginate.java index 0551211f03..6c2bfe8c70 100644 --- a/elide-annotations/src/main/java/com/yahoo/elide/annotation/Paginate.java +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/Paginate.java @@ -5,15 +5,15 @@ */ package com.yahoo.elide.annotation; +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; -import static java.lang.annotation.ElementType.TYPE; -import static java.lang.annotation.RetentionPolicy.RUNTIME; - /** - * Allows pagination options to be set with querying of an entity + * Allows pagination options to be set with querying of an entity. */ @Target({TYPE}) @Retention(RUNTIME) @@ -21,7 +21,7 @@ public @interface Paginate { /** - * Whether or not page totals can be requested + * Whether or not page totals can be requested. * @return the boolean */ boolean countable() default true; diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/ReadPermission.java b/elide-core/src/main/java/com/yahoo/elide/annotation/ReadPermission.java new file mode 100644 index 0000000000..9cdde29a1d --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/ReadPermission.java @@ -0,0 +1,37 @@ +/* + * 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.FIELD; +import static java.lang.annotation.ElementType.METHOD; +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; + +/** + * Assign custom Read permission checks. + */ +@Target({METHOD, FIELD, TYPE, PACKAGE}) +@Retention(RUNTIME) +@Inherited +public @interface ReadPermission { + + /** + * An expression of checks that will be parsed via ANTLR. For example: + * {@code @ReadPermission(expression="Prefab.Role.All")} or + * {@code @ReadPermission(expression="Prefab.Role.All and Prefab.Role.UpdateOnCreate")} + * + * All of {@linkplain com.yahoo.elide.core.security.checks.prefab the built-in checks} are name-spaced as + * {@code Prefab.CHECK} without the {@code Check} suffix + * + * @return the expression string to be parsed + */ + String expression() default ""; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/SecurityCheck.java b/elide-core/src/main/java/com/yahoo/elide/annotation/SecurityCheck.java new file mode 100644 index 0000000000..0285877b91 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/SecurityCheck.java @@ -0,0 +1,52 @@ +/* + * Copyright 2018, the original author or authors. + * Copyright 2019, 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.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A convenience annotation that help you register elide check. + *

+ * Example:
+ *

+ * @SecurityCheck("i am an expression")
+ * public static class{@literal Inline} extends{@literal OperationCheck} {
+ *   @Override
+ *   public boolean ok(Post object, RequestScope requestScope,
+ *       {@literal Optional} changeSpec) {
+ *     return false;
+ *   }
+ * }
+ * 
+ * + * NOTE: The class you annotated must be a {@link com.yahoo.elide.core.security.checks.Check}, + * otherwise a RuntimeException is thrown. + * + * @author olOwOlo + * + * This class is based on https://github.com/illyasviel/elide-spring-boot/blob/master + * /elide-spring-boot-autoconfigure/src/main/java/org/illyasviel/elide/spring/boot/annotation/ElideCheck.java + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +public @interface SecurityCheck { + + /** + * The expression which will be used for + * {@link com.yahoo.elide.annotation.ReadPermission#expression()}, + * {@link com.yahoo.elide.annotation.UpdatePermission#expression()}, + * {@link com.yahoo.elide.annotation.CreatePermission#expression()}, + * {@link com.yahoo.elide.annotation.DeletePermission#expression()}. + * @return The expression you want to defined. + */ + String value(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/ToMany.java b/elide-core/src/main/java/com/yahoo/elide/annotation/ToMany.java new file mode 100644 index 0000000000..9eee221754 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/ToMany.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017, 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.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Marks a method or field as a relationship that should be exposed via Elide Despite JPA bindings. + */ +@Target({METHOD, FIELD}) +@Retention(RUNTIME) +public @interface ToMany { +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/ToOne.java b/elide-core/src/main/java/com/yahoo/elide/annotation/ToOne.java new file mode 100644 index 0000000000..06513a358a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/ToOne.java @@ -0,0 +1,21 @@ +/* + * Copyright 2017, 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.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Marks a method or field as a relationship that should be exposed via Elide Despite JPA bindings. + */ +@Target({METHOD, FIELD}) +@Retention(RUNTIME) +public @interface ToOne { +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/UpdatePermission.java b/elide-core/src/main/java/com/yahoo/elide/annotation/UpdatePermission.java new file mode 100644 index 0000000000..e3db68499c --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/UpdatePermission.java @@ -0,0 +1,37 @@ +/* + * 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.FIELD; +import static java.lang.annotation.ElementType.METHOD; +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; + +/** + * Assign custom Update permission checks. + */ +@Target({METHOD, FIELD, TYPE, PACKAGE}) +@Retention(RUNTIME) +@Inherited +public @interface UpdatePermission { + + /** + * An expression of checks that will be parsed via ANTLR. For example: + * {@code @UpdatePermission(expression="Prefab.Role.All")} or + * {@code @UpdatePermission(expression="Prefab.Role.All and Prefab.Role.UpdateOnCreate")} + * + * All of {@linkplain com.yahoo.elide.core.security.checks.prefab the built-in checks} are name-spaced as + * {@code Prefab.CHECK} without the {@code Check} suffix + * + * @return the expression string to be parsed + */ + String expression() default ""; +} 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 deleted file mode 100644 index 13f52e3cbc..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/audit/AuditLogger.java +++ /dev/null @@ -1,36 +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.audit; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * Base Audit Logger - *

- * This class uses ThreadLocal list to be thread safe. - */ -public abstract class AuditLogger { - protected final ThreadLocal> messages; - - public AuditLogger() { - messages = ThreadLocal.withInitial(() -> { return new ArrayList<>(); }); - } - - public void log(LogMessage message) { - messages.get().add(message); - } - - public abstract void commit() throws IOException; - - public void clear() { - List remainingMessages = messages.get(); - if (remainingMessages != null) { - remainingMessages.clear(); - } - } -} 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 deleted file mode 100644 index 14f9d479e9..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/audit/LogMessage.java +++ /dev/null @@ -1,183 +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.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 de.odysseus.el.ExpressionFactoryImpl; -import de.odysseus.el.util.SimpleContext; - -import javax.el.ELException; -import javax.el.ExpressionFactory; -import javax.el.PropertyNotFoundException; -import javax.el.ValueExpression; -import java.text.MessageFormat; -import java.util.List; -import java.util.Optional; -import java.util.Properties; -import java.util.stream.Collectors; - -/** - * An audit log message that can be logged to a logger. - */ -public class LogMessage { - //Supposedly this is thread safe. - private static final ExpressionFactory EXPRESSION_FACTORY = new ExpressionFactoryImpl(); - private static final String CACHE_SIZE = "5000"; - private static final String[] EMPTY_STRING_ARRAY = new String[0]; - static { - Properties properties = new Properties(); - properties.put("javax.el.cacheSize", CACHE_SIZE); - } - - private final String template; - private final PersistentResource record; - private final String[] expressions; - private final int operationCode; - private final Optional changeSpec; - - /** - * 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 LogMessage(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 LogMessage(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. - * @throws InvalidSyntaxException the invalid syntax exception - */ - 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; - } - - /** - * Gets operation code. - * - * @return the operation code - */ - public int getOperationCode() { - return operationCode; - } - - /** - * Gets message. - * - * @return the message - */ - 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); - } - } - - 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 Optional getChangeSpec() { - return changeSpec; - } - - @Override - public String toString() { - return "LogMessage{" - + "message='" + getMessage() + '\'' - + ", operationCode=" + getOperationCode() - + '}'; - } -} 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 deleted file mode 100644 index d5fac500fb..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/CheckInstantiator.java +++ /dev/null @@ -1,43 +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; - -import com.yahoo.elide.security.checks.Check; - -/** - * Get new instances of a check from a check identifier in an expression. - */ -public interface CheckInstantiator { - /** - * Gets a check instance by first checking the entity dictionary for a mapping on the provided identifier. - * In the event that no such mapping is found the identifier is used as a canonical name. - * @param dictionary the entity dictionary to search for a mapping - * @param checkName the identifier of the check to instantiate - * @return the check instance - * @throws IllegalArgumentException if there is no mapping for {@code checkName} and {@code checkName} is not - * a canonical identifier - */ - default Check getCheck(EntityDictionary dictionary, String checkName) { - Class checkCls = dictionary.getCheck(checkName); - return instantiateCheck(checkCls); - } - - /** - * Instantiates a new instance of a check. - * @param checkCls the check class to instantiate - * @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) { - try { - return checkCls.newInstance(); - } 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 deleted file mode 100644 index 529eb8cb79..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/DataStoreTransaction.java +++ /dev/null @@ -1,238 +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; - -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.filter.Predicate; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.core.sort.Sorting; -import com.yahoo.elide.security.User; - -import java.io.Closeable; -import java.io.Serializable; -import java.util.Collection; -import java.util.Optional; -import java.util.Set; - -/** - * Wraps the Database Transaction type. - */ -public interface DataStoreTransaction extends Closeable { - - /** - * Wrap the opaque user. - * - * @param opaqueUser the opaque user - * @return wrapped user context - */ - default User accessUser(Object opaqueUser) { - return new User(opaqueUser); - } - - /** - * Save entity to database table. Save is called after commit checks have evaluated but before the final - * transaction commit. - * - * @param entity record to save - */ - void save(Object entity); - - /** - * Delete entity from database table. - * - * @param entity record to delete - */ - void delete(Object entity); - - /** - * Write any outstanding entities before processing response. - */ - default void flush() { - } - - /** - * End the current transaction. - */ - void commit(); - - /** - * Called before commit checks are evaluated and before save, flush, and commit are called. - * The sequence goes: - * 1. transaction.preCommit(); - * 2. Invoke security checks evaluated at commit time - * 3. transaction.save(...); - Invoked for every object which changed in the transaction. - * 4. transaction.flush(); - * 5. transaction.commit(); - */ - default void preCommit() { - - } - - /** - * Create new entity record. - * - * @param the type parameter - * @param entityClass the entity class - * @return new record - */ - T createObject(Class entityClass); - - /** - * Read entity record from database table. - * - * @param the type parameter - * @param entityClass the entity class - * @param id ID of object - * @return record t - */ - T loadObject(Class entityClass, Serializable id); - - default T loadObject(Class entityClass, Serializable id, Optional filterExpression) { - return loadObject(entityClass, id); - } - - /** - * Read entity records from database table. - * - * @param the type parameter - * @param entityClass the entity class - * @return records iterable - */ - @Deprecated - Iterable loadObjects(Class entityClass); - - /** - * Read entity records from database table with applied criteria. - * - * @param the type parameter - * @param entityClass the entity class - * @param filterScope scope for filter processing - * @return records iterable - */ - default Iterable loadObjects(Class entityClass, FilterScope filterScope) { - // default to ignoring criteria - return loadObjects(entityClass); - } - - - /** - * Read entity records from database table with applied criteria. - * - * @param the type parameter - * @param entityClass the entity class - * @param filterScope scope for filter processing - * @return records iterable - */ - default Iterable loadObjectsWithSortingAndPagination(Class entityClass, FilterScope filterScope) { - return loadObjects(entityClass, filterScope); - } - - /** - * Get total count of entity records satisfying the given filter. - * - * @param the type parameter - * @param entityClass the entity class - * @return total matching entities - */ - default Long getTotalRecords(Class entityClass) { - // default to no records - return 0L; - } - - - /** - * Filter a collection by the Predicates in filterScope. - * - * @param the type parameter - * @param collection the collection to filter - * @param entityClass the class of the entities in the collection - * @param predicates the set of Predicate's to filter by - * @return the filtered collection - * @deprecated Since 2.4, instead implement the filtering logic in detail methods in implementations - */ - @Deprecated - default Collection filterCollection(Collection collection, Class entityClass, Set predicates) { - return collection; - } - - /** - * Filter Sort and Paginate a collection in filterScope or requestScope. - * @param collection The collection - * @param dictionary The entity dictionary - * @param entityClass The class of the entities in the collection - * @param filters The optional set of Predicate's to filter by - * @param sorting The optional Sorting object - * @param pagination The optional Pagination object - * @param The type parameter - * @return The optionally filtered, sorted and paginated collection - * @deprecated Since 2.4, instead implement the filtering logic in detail methods in implementations - */ - @Deprecated - default Collection filterCollectionWithSortingAndPagination(Collection collection, Class entityClass, - EntityDictionary dictionary, Optional> filters, - Optional sorting, Optional pagination) { - return collection; - } - - @Deprecated - default Object getRelation( - Object entity, - RelationshipType relationshipType, - String relationName, - Class relationClass, - EntityDictionary dictionary, - Set filters - ) { - Object val = PersistentResource.getValue(entity, relationName, dictionary); - if (val instanceof Collection) { - Collection filteredVal = (Collection) val; - - if (!filters.isEmpty()) { - filteredVal = filterCollection(filteredVal, relationClass, filters); - } - return filteredVal; - } - - return val; - } - - default Object getRelation( - Object entity, - RelationshipType relationshipType, - String relationName, - Class relationClass, - EntityDictionary dictionary, - Optional filterExpression, - Sorting sorting, - Pagination pagination - ) { - return PersistentResource.getValue(entity, relationName, dictionary); - } - - - @Deprecated - default Object getRelationWithSortingAndPagination( - Object entity, - RelationshipType relationshipType, - String relationName, - Class relationClass, - EntityDictionary dictionary, - Set filters, - Sorting sorting, - Pagination pagination - ) { - Object val = PersistentResource.getValue(entity, relationName, dictionary); - if (val instanceof Collection) { - Collection filteredVal = (Collection) val; - Optional sortingRules = Optional.ofNullable(sorting); - Optional paginationRules = Optional.ofNullable(pagination); - filteredVal = filterCollectionWithSortingAndPagination(filteredVal, relationClass, dictionary, - Optional.of(filters), sortingRules, paginationRules); - return filteredVal; - } - return val; - } -} 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 deleted file mode 100644 index 5d783de9b9..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/EntityBinding.java +++ /dev/null @@ -1,322 +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; - -import com.yahoo.elide.annotation.ComputedAttribute; -import com.yahoo.elide.annotation.ComputedRelationship; -import com.yahoo.elide.annotation.Exclude; -import com.yahoo.elide.annotation.OnCommit; -import com.yahoo.elide.annotation.OnCreate; -import com.yahoo.elide.annotation.OnDelete; -import com.yahoo.elide.annotation.OnUpdate; -import com.yahoo.elide.core.exceptions.DuplicateMappingException; - -import org.apache.commons.collections4.MultiValuedMap; -import org.apache.commons.collections4.multimap.HashSetValuedHashMap; -import org.apache.commons.lang3.text.WordUtils; -import org.apache.commons.lang3.tuple.Pair; - -import lombok.Getter; -import lombok.Setter; - -import java.lang.annotation.Annotation; -import java.lang.reflect.AccessibleObject; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Deque; -import java.util.List; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedDeque; - -import javax.persistence.Column; -import javax.persistence.Id; -import javax.persistence.ManyToMany; -import javax.persistence.ManyToOne; -import javax.persistence.OneToMany; -import javax.persistence.OneToOne; -import javax.persistence.Transient; - -/** - * Entity Dictionary maps JSON API Entity beans to/from Entity type names. - * - * @see com.yahoo.elide.annotation.Include#type - */ -class EntityBinding { - - private static final List OBJ_METHODS = Arrays.asList(Object.class.getMethods()); - - public final Class entityClass; - public final String jsonApiType; - @Getter private AccessibleObject idField; - @Getter private String idFieldName; - @Getter private Class idType; - @Getter @Setter private Initializer initializer; - - public final EntityPermissions entityPermissions; - public final List attributes; - public final List relationships; - public final ConcurrentLinkedDeque attributesDeque = new ConcurrentLinkedDeque<>(); - public final ConcurrentLinkedDeque relationshipsDeque = new ConcurrentLinkedDeque<>(); - - public final ConcurrentHashMap relationshipTypes = new ConcurrentHashMap<>(); - public final ConcurrentHashMap relationshipToInverse = new ConcurrentHashMap<>(); - public final ConcurrentHashMap fieldsToValues = new ConcurrentHashMap<>(); - public final MultiValuedMap, Method> fieldsToTriggers = new HashSetValuedHashMap<>(); - public final ConcurrentHashMap> fieldsToTypes = new ConcurrentHashMap<>(); - public final ConcurrentHashMap aliasesToFields = new ConcurrentHashMap<>(); - - public final ConcurrentHashMap, Annotation> annotations = new ConcurrentHashMap<>(); - - public static final EntityBinding EMPTY_BINDING = new EntityBinding(); - - /* empty binding constructor */ - private EntityBinding() { - jsonApiType = null; - idField = null; - idType = null; - attributes = null; - relationships = null; - entityClass = null; - entityPermissions = EntityPermissions.EMPTY_PERMISSIONS; - } - - public EntityBinding(EntityDictionary dictionary, Class cls, String type) { - entityClass = cls; - jsonApiType = type; - - // Map id's, attributes, and relationships - List fieldOrMethodList = new ArrayList<>(); - fieldOrMethodList.addAll(Arrays.asList(cls.getFields())); - fieldOrMethodList.addAll(Arrays.asList(cls.getMethods())); - - bindEntityFields(cls, type, fieldOrMethodList); - - attributes = dequeToList(attributesDeque); - relationships = dequeToList(relationshipsDeque); - entityPermissions = new EntityPermissions(dictionary, cls, fieldOrMethodList); - } - - /** - * 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 - */ - private void bindEntityFields(Class cls, String type, Collection fieldOrMethodList) { - for (AccessibleObject fieldOrMethod : fieldOrMethodList) { - bindTriggerIfPresent(OnCreate.class, fieldOrMethod); - bindTriggerIfPresent(OnDelete.class, fieldOrMethod); - bindTriggerIfPresent(OnUpdate.class, fieldOrMethod); - bindTriggerIfPresent(OnCommit.class, fieldOrMethod); - - if (fieldOrMethod.isAnnotationPresent(Id.class)) { - bindEntityId(cls, type, fieldOrMethod); - } else if (fieldOrMethod.isAnnotationPresent(Transient.class) - && !fieldOrMethod.isAnnotationPresent(ComputedAttribute.class) - && !fieldOrMethod.isAnnotationPresent(ComputedRelationship.class)) { - continue; // Transient. Don't serialize - } else if (!fieldOrMethod.isAnnotationPresent(Exclude.class)) { - if (fieldOrMethod instanceof Field && Modifier.isTransient(((Field) fieldOrMethod).getModifiers())) { - continue; // Transient. Don't serialize - } - if (fieldOrMethod instanceof Method && Modifier.isTransient(((Method) fieldOrMethod).getModifiers())) { - continue; // Transient. Don't serialize - } - if (fieldOrMethod instanceof Field - && !fieldOrMethod.isAnnotationPresent(Column.class) - && Modifier.isStatic(((Field) fieldOrMethod).getModifiers())) { - continue; // Field must have Column annotation? - } - bindAttrOrRelation(cls, fieldOrMethod); - } - } - } - - /** - * Bind an id field to an entity. - * - * @param cls Class type to bind fields - * @param type JSON API type identifier - * @param fieldOrMethod Field or method to bind - */ - private void bindEntityId(Class cls, String type, AccessibleObject fieldOrMethod) { - String fieldName = getFieldName(fieldOrMethod); - Class fieldType = getFieldType(fieldOrMethod); - - //Add id field to type map for the entity - fieldsToTypes.put(fieldName, fieldType); - - //Set id field, type, and name - idField = fieldOrMethod; - idType = fieldType; - idFieldName = fieldName; - - fieldsToValues.put(fieldName, fieldOrMethod); - - if (idField != null && !fieldOrMethod.equals(idField)) { - throw new DuplicateMappingException(type + " " + cls.getName() + ":" + fieldName); - } - } - - /** - * Convert a deque to a list. - * - * @param deque Deque to convert - * @return Deque as a list - */ - private static List dequeToList(final Deque deque) { - ArrayList result = new ArrayList<>(); - deque.stream().forEachOrdered(result::add); - result.sort(String.CASE_INSENSITIVE_ORDER); - return Collections.unmodifiableList(result); - } - - /** - * Bind an attribute or relationship. - * - * @param cls Class type to bind fields - * @param fieldOrMethod Field or method to bind - */ - private void bindAttrOrRelation(Class cls, AccessibleObject fieldOrMethod) { - boolean manyToMany = fieldOrMethod.isAnnotationPresent(ManyToMany.class); - boolean manyToOne = fieldOrMethod.isAnnotationPresent(ManyToOne.class); - boolean oneToMany = fieldOrMethod.isAnnotationPresent(OneToMany.class); - boolean oneToOne = fieldOrMethod.isAnnotationPresent(OneToOne.class); - boolean computedRelationship = fieldOrMethod.isAnnotationPresent(ComputedRelationship.class); - boolean isRelation = manyToMany || manyToOne || oneToMany || oneToOne; - - String fieldName = getFieldName(fieldOrMethod); - - if (fieldName == null || fieldName.equals("id") - || fieldName.equals("class") || OBJ_METHODS.contains(fieldOrMethod)) { - return; // Reserved. Not attributes. - } - - Class fieldType = getFieldType(fieldOrMethod); - - ConcurrentLinkedDeque fieldList; - if (isRelation) { - fieldList = relationshipsDeque; - RelationshipType type; - String mappedBy; - if (oneToMany) { - type = computedRelationship ? RelationshipType.COMPUTED_ONE_TO_MANY : RelationshipType.ONE_TO_MANY; - mappedBy = fieldOrMethod.getAnnotation(OneToMany.class).mappedBy(); - } else if (oneToOne) { - type = computedRelationship ? RelationshipType.COMPUTED_ONE_TO_ONE : RelationshipType.ONE_TO_ONE; - mappedBy = fieldOrMethod.getAnnotation(OneToOne.class).mappedBy(); - } else if (manyToMany) { - type = computedRelationship ? RelationshipType.COMPUTED_MANY_TO_MANY : RelationshipType.MANY_TO_MANY; - mappedBy = fieldOrMethod.getAnnotation(ManyToMany.class).mappedBy(); - } else if (manyToOne) { - type = computedRelationship ? RelationshipType.COMPUTED_MANY_TO_ONE : RelationshipType.MANY_TO_ONE; - mappedBy = ""; - } else { - type = computedRelationship ? RelationshipType.COMPUTED_NONE : RelationshipType.NONE; - mappedBy = ""; - } - relationshipTypes.put(fieldName, type); - relationshipToInverse.put(fieldName, mappedBy); - } else { - fieldList = attributesDeque; - } - - fieldList.push(fieldName); - fieldsToValues.put(fieldName, fieldOrMethod); - fieldsToTypes.put(fieldName, fieldType); - } - - /** - * Returns name of field whether public member or method. - * - * @param fieldOrMethod field or method - * @return field or method name - */ - public static String getFieldName(AccessibleObject fieldOrMethod) { - if (fieldOrMethod instanceof Field) { - return ((Field) fieldOrMethod).getName(); - } else { - Method method = (Method) fieldOrMethod; - String name = method.getName(); - - if (name.startsWith("get") && method.getParameterCount() == 0) { - name = WordUtils.uncapitalize(name.substring("get".length())); - } else if (name.startsWith("is") && method.getParameterCount() == 0) { - name = WordUtils.uncapitalize(name.substring("is".length())); - } else { - return null; - } - return name; - } - } - - /** - * Returns type of field whether public member or method. - * - * @param fieldOrMethod field or method - * @return field type - */ - private static Class getFieldType(AccessibleObject fieldOrMethod) { - if (fieldOrMethod instanceof Field) { - return ((Field) fieldOrMethod).getType(); - } else { - return ((Method) fieldOrMethod).getReturnType(); - } - } - - 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 = ""; - } - fieldsToTriggers.put(Pair.of(annotationClass, value), (Method) fieldOrMethod); - } - } - - public Collection getTriggers(Class annotationClass, String fieldName) { - Collection methods = fieldsToTriggers.get(Pair.of(annotationClass, fieldName)); - return methods == null ? Collections.emptyList() : methods; - } - - /** - * Cache placeholder for no annotation. - */ - private static final Annotation NO_ANNOTATION = new Annotation() { - @Override - public Class annotationType() { - return null; - } - }; - - /** - * Return annotation from class, parents or package. - * - * @param annotationClass the annotation class - * @param annotation type - * @return the annotation - */ - public A getAnnotation(Class annotationClass) { - Annotation annotation = annotations.get(annotationClass); - if (annotation == null) { - annotation = EntityDictionary.getFirstAnnotation(entityClass, Collections.singletonList(annotationClass)); - if (annotation == null) { - annotation = NO_ANNOTATION; - } - annotations.putIfAbsent(annotationClass, annotation); - } - return annotation == NO_ANNOTATION ? null : (A) annotation; - } -} 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 deleted file mode 100644 index 785d0a2134..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/EntityDictionary.java +++ /dev/null @@ -1,810 +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; - -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.SharePermission; -import com.yahoo.elide.core.exceptions.DuplicateMappingException; -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.google.common.collect.BiMap; -import com.google.common.collect.HashBiMap; -import com.google.common.collect.Maps; - -import org.antlr.v4.runtime.tree.ParseTree; -import org.apache.commons.lang3.text.WordUtils; - -import lombok.extern.slf4j.Slf4j; - -import java.lang.annotation.Annotation; -import java.lang.reflect.AccessibleObject; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; - -import javax.persistence.Entity; -import javax.persistence.Transient; - -/** - * Entity Dictionary maps JSON API Entity beans to/from Entity type names. - * - * @see Include#type - */ -@Slf4j -@SuppressWarnings("static-method") -public class EntityDictionary { - - protected final ConcurrentHashMap> bindJsonApiToEntity = new ConcurrentHashMap<>(); - protected final ConcurrentHashMap, EntityBinding> entityBindings = new ConcurrentHashMap<>(); - protected final CopyOnWriteArrayList> bindEntityRoots = new CopyOnWriteArrayList<>(); - protected final BiMap> checkNames; - - /** - * Instantiates a new Entity dictionary. - * @deprecated As of 2.2, {@link #EntityDictionary(Map)} is preferred. - */ - @Deprecated - public EntityDictionary() { - this(new ConcurrentHashMap<>()); - } - - /** - * Instantiate a new EntityDictionary with the provided set of checks. In addition all of the checks - * in {@link com.yahoo.elide.security.checks.prefab} are mapped to {@code Prefab.CONTAINER.CHECK} - * (e.g. {@code @ReadPermission(expression="Prefab.Role.All")} - * or {@code @ReadPermission(expression="Prefab.Common.UpdateOnCreate")}) - * @param checks a map that links the identifiers used in the permission expression strings - * to their implementing classes - */ - public EntityDictionary(Map> checks) { - checkNames = Maps.synchronizedBiMap(HashBiMap.create(checks)); - - 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); - } - - private void addPrefabCheck(String alias, Class checkClass) { - if (checkNames.containsKey(alias) || checkNames.inverse().containsKey(checkClass)) { - return; - } - - checkNames.put(alias, checkClass); - } - - private static Package getParentPackage(Package pkg) { - String name = pkg.getName(); - int idx = name.lastIndexOf('.'); - return idx == -1 ? null : Package.getPackage(name.substring(0, idx)); - } - - /** - * Find an arbitrary method. - * - * @param entityClass the entity class - * @param name the name - * @param paramClass the param class - * @return method method - * @throws NoSuchMethodException the no such method exception - */ - public static Method findMethod(Class entityClass, String name, Class... paramClass) - throws NoSuchMethodException { - Method m = entityClass.getMethod(name, paramClass); - int modifiers = m.getModifiers(); - if (Modifier.isAbstract(modifiers) - || m.isAnnotationPresent(Transient.class) - && !m.isAnnotationPresent(ComputedAttribute.class) - && !m.isAnnotationPresent(ComputedRelationship.class)) { - throw new NoSuchMethodException(name); - } - return m; - } - - protected EntityBinding getEntityBinding(Class entityClass) { - return entityBindings.getOrDefault(lookupEntityClass(entityClass), EntityBinding.EMPTY_BINDING); - } - - /** - * Returns the binding class for a given entity name. - * - * @param entityName entity name - * @return binding class - */ - public Class getEntityClass(String entityName) { - return bindJsonApiToEntity.get(entityName); - } - - /** - * Returns the entity name for a given binding class. - * - * @param entityClass the entity class - * @return binding class - */ - public String getJsonAliasFor(Class entityClass) { - return getEntityBinding(entityClass).jsonApiType; - } - - /** - * Determine if a given (entity class, permission) pair have any permissions defined. - * - * @param resourceClass the entity class - * @param annotationClass the permission annotation - * @return {@code true} if that permission is defined anywhere within the class - */ - public boolean entityHasChecksForPermission(Class resourceClass, Class annotationClass) { - EntityBinding binding = getEntityBinding(resourceClass); - return binding.entityPermissions.hasChecksForPermission(annotationClass); - } - - /** - * Gets the specified permission definition (if any) at the class level. - * - * @param resourceClass the entity to check - * @param annotationClass the permission to look for - * @return a {@code ParseTree} expressing the permissions, if one exists - * or {@code null} if the permission is not specified at a class level - */ - public ParseTree getPermissionsForClass(Class resourceClass, - Class annotationClass) { - EntityBinding binding = getEntityBinding(resourceClass); - return binding.entityPermissions.getClassChecksForPermission(annotationClass); - } - - /** - * Gets the specified permission definition (if any) at the class level. - * - * @param resourceClass the entity to check - * @param field the field to inspect - * @param annotationClass the permission to look for - * @return a {@code ParseTree} expressing the permissions, if one exists - * or {@code null} if the permission is not specified on that field - */ - public ParseTree getPermissionsForField(Class resourceClass, - String field, - Class annotationClass) { - EntityBinding binding = getEntityBinding(resourceClass); - return binding.entityPermissions.getFieldChecksForPermission(field, annotationClass); - } - - /** - * Returns the check mapped to a particular identifier. - * - * @param checkIdentifier the name from the expression string - * @return the {@link Check} mapped to the identifier or {@code null} if the given identifer is unmapped - */ - public Class getCheck(String checkIdentifier) { - Class checkCls = checkNames.get(checkIdentifier); - - if (checkCls == null) { - try { - checkCls = (Class) Class.forName(checkIdentifier); - try { - checkNames.putIfAbsent(checkIdentifier, checkCls); - } catch (IllegalArgumentException e) { - log.error("HELP! {} {} {}", checkIdentifier, checkCls, checkNames.inverse().get(checkCls)); - throw e; - } - } catch (ClassNotFoundException | ClassCastException e) { - throw new IllegalArgumentException( - "Could not instantiate specified check '" + checkIdentifier + "'.", e); - } - } - - return checkCls; - } - - /** - * Returns the friendly named mapped to this given check. - * @param checkClass The class to lookup - * @return the friendly name of the check. - */ - public String getCheckIdentifier(Class checkClass) { - String identifier = checkNames.inverse().get(checkClass); - - if (identifier == null) { - return checkClass.getName(); - } - return identifier; - } - - /** - * Returns the name of the id field. - * - * @param entityClass Entity class - * @return id field name - */ - public String getIdFieldName(Class entityClass) { - return getEntityBinding(entityClass).getIdFieldName(); - } - - /** - * Get all bindings. - * - * @return the bindings - */ - public Set> getBindings() { - return entityBindings.keySet(); - } - - /** - * Get the check mappings. - * @return a map of check mappings this dictionary knows about - */ - public Map> getCheckMappings() { - return checkNames; - } - - /** - * Get the list of attribute names for an entity. - * - * @param entityClass entity name - * @return List of attribute names for entity - */ - public List getAttributes(Class entityClass) { - return getEntityBinding(entityClass).attributes; - } - - /** - * Get the list of attribute names for an entity. - * - * @param entity entity instance - * @return List of attribute names for entity - */ - public List getAttributes(Object entity) { - return getAttributes(entity.getClass()); - } - - /** - * Get the list of relationship names for an entity. - * - * @param entityClass entity name - * @return List of relationship names for entity - */ - public List getRelationships(Class entityClass) { - return getEntityBinding(entityClass).relationships; - } - - /** - * Get the list of relationship names for an entity. - * - * @param entity entity instance - * @return List of relationship names for entity - */ - public List getRelationships(Object entity) { - return getRelationships(entity.getClass()); - } - - /** - * Get a list of all fields including both relationships and attributes. - * - * @param entityClass entity name - * @return List of all fields. - */ - public List getAllFields(Class entityClass) { - List fields = new ArrayList<>(); - - List attrs = getAttributes(entityClass); - List rels = getRelationships(entityClass); - - if (attrs != null) { - fields.addAll(attrs); - } - - if (rels != null) { - fields.addAll(rels); - } - - return fields; - } - - /** - * Get a list of all fields including both relationships and attributes. - * - * @param entity entity - * @return List of all fields. - */ - public List getAllFields(Object entity) { - return getAllFields(entity.getClass()); - } - - /** - * Get the type of relationship from a relation. - * - * @param cls Entity class - * @param relation Name of relationship field - * @return Relationship type. RelationshipType.NONE if is none found. - */ - public RelationshipType getRelationshipType(Class cls, String relation) { - final ConcurrentHashMap types = getEntityBinding(cls).relationshipTypes; - if (types == null) { - return RelationshipType.NONE; - } - final RelationshipType type = types.get(relation); - return (type == null) ? RelationshipType.NONE : type; - } - - /** - * If a relationship is bidirectional, returns the name of the peer relationship in the peer entity. - * - * @param cls the cls - * @param relation the relation - * @return relation inverse - */ - public String getRelationInverse(Class cls, String relation) { - final EntityBinding clsBinding = getEntityBinding(cls); - final ConcurrentHashMap mappings = clsBinding.relationshipToInverse; - if (mappings != null) { - final String mapping = mappings.get(relation); - - if (mapping != null && !mapping.equals("")) { - return mapping; - } - } - - /* - * This could be the owning side of the relation. Let's see if the entity referenced in the relation - * has a bidirectional reference that is mapped to the given relation. - */ - final Class inverseType = getParameterizedType(cls, relation); - final ConcurrentHashMap inverseMappings = - getEntityBinding(inverseType).relationshipToInverse; - - for (Map.Entry inverseMapping : inverseMappings.entrySet()) { - String inverseRelationName = inverseMapping.getKey(); - String inverseMappedBy = inverseMapping.getValue(); - - if (relation.equals(inverseMappedBy) - && getParameterizedType(inverseType, inverseRelationName).equals(clsBinding.entityClass)) { - return inverseRelationName; - } - - } - return ""; - } - - /** - * Get the type of relationship from a relation. - * - * @param entity Entity instance - * @param relation Name of relationship field - * @return Relationship type. RelationshipType.NONE if is none found. - */ - public RelationshipType getRelationshipType(Object entity, String relation) { - return getRelationshipType(entity.getClass(), relation); - } - - /** - * Get a type for a field on an entity. - * - * @param entityClass Entity class - * @param identifier Field to lookup type - * @return Type of entity - */ - public Class getType(Class entityClass, String identifier) { - ConcurrentHashMap> fieldTypes = getEntityBinding(entityClass).fieldsToTypes; - return fieldTypes == null ? null : fieldTypes.get(identifier); - } - - /** - * Get a type for a field on an entity. - * - * @param entity Entity instance - * @param identifier Field to lookup type - * @return Type of entity - */ - public Class getType(Object entity, String identifier) { - return getType(entity.getClass(), identifier); - } - - /** - * Retrieve the parameterized type for the given field. - * - * @param entityClass the entity class - * @param identifier the identifier - * @return Entity type for field otherwise null. - */ - public Class getParameterizedType(Class entityClass, String identifier) { - return getParameterizedType(entityClass, identifier, 0); - } - - /** - * Retrieve the parameterized type for the given field. - * - * @param entityClass the entity class - * @param identifier the identifier - * @param paramIndex the index of the parameterization - * @return Entity type for field otherwise null. - */ - public Class getParameterizedType(Class entityClass, String identifier, int paramIndex) { - ConcurrentHashMap fieldOrMethods = getEntityBinding(entityClass).fieldsToValues; - if (fieldOrMethods == null) { - return null; - } - AccessibleObject fieldOrMethod = fieldOrMethods.get(identifier); - if (fieldOrMethod == null) { - return null; - } - - Type type; - - if (fieldOrMethod instanceof Method) { - type = ((Method) fieldOrMethod).getGenericReturnType(); - } else { - type = ((Field) fieldOrMethod).getGenericType(); - } - - if (type instanceof ParameterizedType) { - return (Class) ((ParameterizedType) type).getActualTypeArguments()[paramIndex]; - } - - return getType(entityClass, identifier); - } - - /** - * Retrieve the parameterized type for the given field. - * - * @param entity Entity instance - * @param identifier Field to lookup - * @return Entity type for field otherwise null. - */ - public Class getParameterizedType(Object entity, String identifier) { - return getParameterizedType(entity.getClass(), identifier); - } - - /** - * Retrieve the parameterized type for the given field. - * - * @param entity Entity instance - * @param identifier Field to lookup - * @param paramIndex the index of the parameterization - * @return Entity type for field otherwise null. - */ - public Class getParameterizedType(Object entity, String identifier, int paramIndex) { - return getParameterizedType(entity.getClass(), identifier, paramIndex); - } - - /** - * Get the true field/method name from an alias. - * - * @param entityClass Entity name - * @param alias Alias to convert - * @return Real field/method name as a string. null if not found. - */ - public String getNameFromAlias(Class entityClass, String alias) { - ConcurrentHashMap map = getEntityBinding(entityClass).aliasesToFields; - if (map != null) { - return map.get(alias); - } - return null; - } - - /** - * Get the true field/method name from an alias. - * - * @param entity Entity instance - * @param alias Alias to convert - * @return Real field/method name as a string. null if not found. - */ - public String getNameFromAlias(Object entity, String alias) { - return getNameFromAlias(entity.getClass(), alias); - } - - /** - * Initialize an entity. - * - * @param the type parameter - * @param entity Entity to initialize - */ - public void initializeEntity(T entity) { - if (entity != null) { - @SuppressWarnings("unchecked") - Initializer initializer = getEntityBinding(entity.getClass()).getInitializer(); - if (initializer != null) { - initializer.initialize(entity); - } - } - } - - /** - * Bind a particular initializer to a class. - * - * @param the type parameter - * @param initializer Initializer to use for class - * @param cls Class to bind initialization - */ - public void bindInitializer(Initializer initializer, Class cls) { - getEntityBinding(cls).setInitializer(initializer); - } - - /** - * Returns whether or not an entity is shareable. - * - * @param entityClass the entity type to check for the shareable permissions - * @return true if entityClass is shareable. False otherwise. - */ - public boolean isShareable(Class entityClass) { - return getAnnotation(entityClass, SharePermission.class) != null; - } - - /** - * Add given Entity bean to dictionary. - * - * @param cls Entity bean class - */ - public void bindEntity(Class cls) { - Annotation annotation = getFirstAnnotation(cls, Arrays.asList(Include.class, Exclude.class)); - Include include = annotation instanceof Include ? (Include) annotation : null; - Exclude exclude = annotation instanceof Exclude ? (Exclude) annotation : null; - - if (exclude != null) { - log.trace("Exclude {}", cls.getName()); - return; - } - - if (include == null) { - log.trace("Missing include {}", cls.getName()); - return; - } - - String type; - if ("".equals(include.type())) { - type = WordUtils.uncapitalize(cls.getSimpleName()); - } else { - type = include.type(); - } - - Class duplicate = bindJsonApiToEntity.put(type, cls); - if (duplicate != null && !duplicate.equals(cls)) { - log.error("Duplicate binding {} for {}, {}", type, cls, duplicate); - throw new DuplicateMappingException(type + " " + cls.getName() + ":" + duplicate.getName()); - } - - entityBindings.putIfAbsent(lookupEntityClass(cls), new EntityBinding(this, cls, type)); - if (include.rootLevel()) { - bindEntityRoots.add(cls); - } - } - - /** - * Return annotation from class, parents or package. - * - * @param record the record - * @param annotationClass the annotation class - * @param genericClass - * @return the annotation - */ - public A getAnnotation(PersistentResource record, Class annotationClass) { - return getAnnotation(record.getResourceClass(), annotationClass); - } - - /** - * Return annotation from class, parents or package. - * - * @param recordClass the record class - * @param annotationClass the annotation class - * @param genericClass - * @return the annotation - */ - public A getAnnotation(Class recordClass, Class annotationClass) { - return getEntityBinding(recordClass).getAnnotation(annotationClass); - } - - public Collection getTriggers(Class cls, - Class annotationClass, - String fieldName) { - return getEntityBinding(cls).getTriggers(annotationClass, fieldName); - } - - /** - * Return a single annotation from field or accessor method. - * - * @param entityClass the entity class - * @param annotationClass given annotation type - * @param identifier the identifier - * @param genericClass - * @return annotation found - */ - public A getAttributeOrRelationAnnotation(Class entityClass, - Class annotationClass, - String identifier) { - AccessibleObject fieldOrMethod = getEntityBinding(entityClass).fieldsToValues.get(identifier); - if (fieldOrMethod == null) { - return null; - } - return fieldOrMethod.getAnnotation(annotationClass); - } - - /** - * Return multiple annotations from field or accessor method. - * - * @param the type parameter - * @param entityClass the entity class - * @param annotationClass given annotation type - * @param identifier the identifier - * @return annotation found or null if none found - */ - public A[] getAttributeOrRelationAnnotations(Class entityClass, - Class annotationClass, - String identifier) { - AccessibleObject fieldOrMethod = getEntityBinding(entityClass).fieldsToValues.get(identifier); - if (fieldOrMethod == null) { - return null; - } - return fieldOrMethod.getAnnotationsByType(annotationClass); - } - - /** - * Return first matching annotation from class, parents or package. - * - * @param entityClass Entity class type - * @param annotationClassList List of sought annotations - * @return annotation found - */ - public static Annotation getFirstAnnotation(Class entityClass, - List> annotationClassList) { - Annotation annotation = null; - for (Class cls = entityClass; annotation == null && cls != null; cls = cls.getSuperclass()) { - for (Class annotationClass : annotationClassList) { - annotation = cls.getAnnotation(annotationClass); - if (annotation != null) { - break; - } - } - } - // no class annotation, try packages - for (Package pkg = entityClass.getPackage(); annotation == null && pkg != null; pkg = getParentPackage(pkg)) { - for (Class annotationClass : annotationClassList) { - annotation = pkg.getAnnotation(annotationClass); - if (annotation != null) { - break; - } - } - } - return annotation; - } - - /** - * Is root. - * - * @param entityClass the entity class - * @return the boolean - */ - public boolean isRoot(Class entityClass) { - return bindEntityRoots.contains(entityClass); - } - - /** - * Gets id. - * - * @param value the value - * @return the id - */ - public String getId(Object value) { - if (value == null) { - return null; - } - try { - AccessibleObject idField = null; - for (Class cls = value.getClass(); idField == null && cls != null; cls = cls.getSuperclass()) { - idField = getEntityBinding(cls).getIdField(); - } - if (idField instanceof Field) { - return String.valueOf(((Field) idField).get(value)); - } - if (idField instanceof Method) { - return String.valueOf(((Method) idField).invoke(value, (Object[]) null)); - } - return null; - } catch (IllegalArgumentException | IllegalAccessException | InvocationTargetException e) { - return null; - } - } - - /** - * Returns type of id field. - * - * @param entityClass the entity class - * @return ID type - */ - public Class getIdType(Class entityClass) { - return getEntityBinding(entityClass).getIdType(); - } - - /** - * Returns annotations applied to the ID field. - * - * @param value the value - * @return Collection of Annotations - */ - public Collection getIdAnnotations(Object value) { - if (value == null) { - return null; - } - - AccessibleObject idField = getEntityBinding(value.getClass()).getIdField(); - if (idField != null) { - return Arrays.asList(idField.getDeclaredAnnotations()); - } - - return Collections.emptyList(); - } - - /** - * Follow for this class or super-class for Entity annotation. - * - * @param objClass provided class - * @return class with Entity annotation - */ - public Class lookupEntityClass(Class objClass) { - for (Class cls = objClass; cls != null; cls = cls.getSuperclass()) { - if (entityBindings.containsKey(cls) || cls.isAnnotationPresent(Entity.class)) { - return cls; - } - } - throw new IllegalArgumentException("Unknown Entity " + objClass); - } - - /** - * Retrieve the accessible object for a field from a target object. - * - * @param target the object to get - * @param fieldName the field name to get or invoke equivalent get method - * @return the value - */ - public AccessibleObject getAccessibleObject(Object target, String fieldName) { - return getAccessibleObject(target.getClass(), fieldName); - } - - /** - * Retrieve the accessible object for a field. - * - * @param targetClass the object to get - * @param fieldName the field name to get or invoke equivalent get method - * @return the value - */ - public AccessibleObject getAccessibleObject(Class targetClass, String fieldName) { - return getEntityBinding(targetClass).fieldsToValues.get(fieldName); - } - - /** - * Retrieve fields from an object containing a particular type. - * - * @param targetClass Class to search for fields - * @param targetType Type of fields to find - * @return Set containing field names - */ - public Set getFieldsOfType(Class targetClass, Class targetType) { - HashSet fields = new HashSet<>(); - for (String field : getAllFields(targetClass)) { - if (getParameterizedType(targetClass, field).equals(targetType)) { - fields.add(field); - } - } - return fields; - } -} 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 deleted file mode 100644 index b48c57453d..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/EntityPermissions.java +++ /dev/null @@ -1,192 +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; - -import com.yahoo.elide.annotation.CreatePermission; -import com.yahoo.elide.annotation.DeletePermission; -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; -import com.yahoo.elide.security.checks.Check; -import lombok.extern.slf4j.Slf4j; -import org.antlr.v4.runtime.ANTLRInputStream; -import org.antlr.v4.runtime.BaseErrorListener; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.RecognitionException; -import org.antlr.v4.runtime.Recognizer; -import org.antlr.v4.runtime.misc.ParseCancellationException; -import org.antlr.v4.runtime.tree.ParseTree; - -import java.lang.annotation.Annotation; -import java.lang.reflect.AccessibleObject; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Extract permissions related annotation data for a model. - */ -@Slf4j -public class EntityPermissions implements CheckInstantiator { - private EntityDictionary dictionary; - private static final List> PERMISSION_ANNOTATIONS = Arrays.asList( - ReadPermission.class, - CreatePermission.class, - DeletePermission.class, - SharePermission.class, - UpdatePermission.class - ); - - public static final EntityPermissions EMPTY_PERMISSIONS = new EntityPermissions(); - - private static final AnnotationBinding EMPTY_BINDING = new AnnotationBinding(null, Collections.emptyMap()); - private final HashMap, AnnotationBinding> bindings = new HashMap<>(); - - private static class AnnotationBinding { - final ParseTree classPermission; - final Map fieldPermissions; - - public AnnotationBinding(ParseTree classPermission, Map fieldPermissions) { - this.classPermission = classPermission; - this.fieldPermissions = fieldPermissions.isEmpty() ? Collections.emptyMap() : fieldPermissions; - } - } - - - private EntityPermissions() { - } - - /** - * Create bindings for entity class to its permission checks. - * @param cls entity class - * @param fieldOrMethodList list of fields/methods - */ - public EntityPermissions(EntityDictionary dictionary, - Class cls, - Collection fieldOrMethodList) { - this.dictionary = dictionary; - for (Class annotationClass : PERMISSION_ANNOTATIONS) { - ParseTree classPermission = bindClassPermissions(cls, annotationClass); - final Map fieldPermissions = new HashMap<>(); - fieldOrMethodList.stream() - .forEach(member -> bindMemberPermissions(fieldPermissions, member, annotationClass)); - if (classPermission != null || !fieldPermissions.isEmpty()) { - bindings.put(annotationClass, new AnnotationBinding(classPermission, fieldPermissions)); - } - } - } - - private ParseTree bindClassPermissions(Class cls, Class annotationClass) { - Annotation annotation = EntityDictionary.getFirstAnnotation(cls, Arrays.asList(annotationClass)); - return (annotation == null) ? null : getPermissionExpressionTree(annotationClass, annotation); - } - - private void bindMemberPermissions(Map fieldPermissions, - AccessibleObject field, Class annotationClass) { - Annotation annotation = field.getAnnotation(annotationClass); - if (annotation != null) { - ParseTree permissions = getPermissionExpressionTree(annotationClass, annotation); - fieldPermissions.put(EntityBinding.getFieldName(field), permissions); - } - } - - private ParseTree getPermissionExpressionTree(Class annotationClass, Annotation annotation) { - try { - String expression = (String) annotationClass.getMethod("expression").invoke(annotation); - Class[] allChecks = (Class[]) annotationClass.getMethod("all") - .invoke(annotation); - Class[] anyChecks = (Class[]) annotationClass.getMethod("any") - .invoke(annotation); - - boolean hasAnyChecks = anyChecks.length > 0; - boolean hasAllChecks = allChecks.length > 0; - boolean hasExpression = !expression.isEmpty(); - - boolean hasConfiguredChecks = hasAnyChecks || hasAllChecks || hasExpression; - boolean hasConfiguredOneChecks = hasAnyChecks ^ hasAllChecks ^ hasExpression; - - if (!hasConfiguredChecks || !hasConfiguredOneChecks) { - log.warn("Poorly configured permission: {} {}", annotationClass.getName(), - hasConfiguredChecks ? "more than one set of checks specified" : "no checks specified."); - throw new IllegalArgumentException("Poorly configured permission '" + annotationClass.getName() + "'"); - } - - if (allChecks.length > 0) { - expression = listToExpression(allChecks, " and "); - } else if (anyChecks.length > 0) { - expression = listToExpression(anyChecks, " or "); - } - - return parseExpression(expression); - } catch (ReflectiveOperationException e) { - log.warn("Unknown permission: {}, {}", annotationClass.getName(), e); - throw new IllegalArgumentException("Unknown permission '" + annotationClass.getName() + "'", e); - } - } - - private String listToExpression(Class[] allChecks, String conjunction) { - String expression; - expression = Arrays.asList(allChecks) - .stream() - .map(this::instantiateCheck) - .map(check -> dictionary.getCheckIdentifier((Class) check.getClass())) - .reduce("", - (current, next) -> current.isEmpty() - ? next - : current + conjunction + next - ); - return expression; - } - - private ParseTree parseExpression(String expression) { - ANTLRInputStream is = new ANTLRInputStream(expression); - ExpressionLexer lexer = new ExpressionLexer(is); - lexer.removeErrorListeners(); - lexer.addErrorListener(new BaseErrorListener() { - @Override - public void syntaxError(Recognizer recognizer, Object offendingSymbol, int line, - int charPositionInLine, String msg, RecognitionException e) { - throw new ParseCancellationException(msg, e); - } - }); - ExpressionParser parser = new ExpressionParser(new CommonTokenStream(lexer)); - lexer.reset(); - return parser.start(); - } - - /** - * Does this permission check annotation exist for this entity or any field? - * @param annotationClass permission class - * @return true if annotation exists - */ - public boolean hasChecksForPermission(Class annotationClass) { - return bindings.containsKey(annotationClass); - } - - /** - * Get entity permission ParseTree. - * @param annotationClass permission class - * @return entity permission ParseTree or null if none - */ - public ParseTree getClassChecksForPermission(Class annotationClass) { - return bindings.getOrDefault(annotationClass, EMPTY_BINDING).classPermission; - } - - /** - * Get field permission ParseTree for provided name. - * @param field provided field name - * @param annotationClass permission class - * @return entity permission ParseTree or null if none - */ - public ParseTree getFieldChecksForPermission(String field, Class annotationClass) { - return bindings.getOrDefault(annotationClass, EMPTY_BINDING).fieldPermissions.get(field); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/FilterScope.java b/elide-core/src/main/java/com/yahoo/elide/core/FilterScope.java deleted file mode 100644 index d216605504..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/FilterScope.java +++ /dev/null @@ -1,61 +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; - -import com.yahoo.elide.annotation.ReadPermission; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.antlr.v4.runtime.tree.ParseTree; - -import java.util.function.BiFunction; -import java.util.function.Function; - -/** - * Scope for filter processing. Contains requestScope and checks. - */ -@Slf4j -public class FilterScope { - - @Getter private final RequestScope requestScope; - @Getter private final ParseTree permissions; - - public FilterScope(RequestScope requestScope, Class resourceClass) { - this.requestScope = requestScope; - this.permissions = requestScope.getDictionary().getPermissionsForClass(resourceClass, ReadPermission.class); - } - - public T getCriterion(Function criterionNegater, - BiFunction andCriterionJoiner, - BiFunction orCriterionJoiner) { - return requestScope.getPermissionExecutor().getCriterion( - permissions, - criterionNegater, - andCriterionJoiner, - orCriterionJoiner); - } - - /** - * Returns true if pagination limits were added to this query. - * - * NOTE: This method is often used in GET transaction implementations - * - * @return true if there is pagination filtering - */ - public boolean hasSortingRules() { - return !requestScope.getSorting().isDefaultInstance(); - } - - /** - * Returns true if pagination limits were added to this query. - * - * NOTE: This method is often used in GET transaction implementations - * - * @return true if there is pagination filtering - */ - public boolean hasPagination() { - return requestScope.getPagination() != null; - } -} 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 71edd365d2..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/Initializer.java +++ /dev/null @@ -1,21 +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; - -/** - * Used to perform any additional initialization required on entity beans which is not - * possible at time of construction. - * @param bean type - */ -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/ObjectEntityCache.java b/elide-core/src/main/java/com/yahoo/elide/core/ObjectEntityCache.java index 4bde823512..eb2e56322f 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/ObjectEntityCache.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/ObjectEntityCache.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.core; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.Map; @@ -20,7 +21,7 @@ public class ObjectEntityCache { */ public ObjectEntityCache() { resourceCache = new LinkedHashMap<>(); - uuidReverseMap = new LinkedHashMap<>(); + uuidReverseMap = new IdentityHashMap<>(); } /** 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 new file mode 100644 index 0000000000..970d78eabc --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/Path.java @@ -0,0 +1,197 @@ +/* + * 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; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.getSimpleName; +import static com.yahoo.elide.core.utils.TypeHelper.appendAlias; +import static com.yahoo.elide.core.utils.TypeHelper.getTypeAlias; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.InvalidValueException; +import com.yahoo.elide.core.request.Argument; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import org.apache.commons.collections4.CollectionUtils; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Represents a path in the entity relationship graph. + */ +@EqualsAndHashCode +public class Path { + private static final String PERIOD = "."; + + @Getter protected List pathElements; + /** + * The path taken through data model associations to reference a given field. + * eg. author.books.publisher.name + */ + @AllArgsConstructor + @ToString + @EqualsAndHashCode + public static class PathElement { + @Getter private Type type; + @Getter private Type fieldType; + @Getter private String fieldName; + @Getter private String alias; + @Getter private Set arguments; + + public PathElement(Class type, Class fieldType, String fieldName) { + this(ClassType.of(type), ClassType.of(fieldType), fieldName); + } + + public PathElement(Type type, Type fieldType, String fieldName) { + this.type = type; + this.fieldType = fieldType; + this.fieldName = fieldName; + this.alias = fieldName; + this.arguments = Collections.emptySet(); + } + } + + protected Path() { + } + + public Path(Path copy) { + this(copy.pathElements); + } + + public Path(List pathElements) { + this.pathElements = ImmutableList.copyOf(pathElements); + } + + public Path(Class entityClass, EntityDictionary dictionary, String dotSeparatedPath) { + this(ClassType.of(entityClass), dictionary, dotSeparatedPath); + } + + public Path(Type entityClass, EntityDictionary dictionary, String dotSeparatedPath) { + pathElements = resolvePathElements(entityClass, dictionary, dotSeparatedPath); + } + + public Path(Class entityClass, EntityDictionary dictionary, String fieldName, + String alias, Set arguments) { + this(ClassType.of(entityClass), dictionary, fieldName, alias, arguments); + } + + public Path(Type entityClass, EntityDictionary dictionary, String fieldName, + String alias, Set arguments) { + pathElements = Lists.newArrayList(resolvePathAttribute(entityClass, fieldName, alias, arguments, dictionary)); + } + + public boolean isComputed(EntityDictionary dictionary) { + for (Path.PathElement pathElement : getPathElements()) { + Type entityClass = pathElement.getType(); + String fieldName = pathElement.getFieldName(); + + if (dictionary.isComputed(entityClass, fieldName)) { + return true; + } + } + return false; + } + + /** + * 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"] + */ + protected List resolvePathElements(Type entityClass, + EntityDictionary dictionary, + String dotSeparatedPath) { + List elements = new ArrayList<>(); + String[] fieldNames = dotSeparatedPath.split("\\."); + + Type currentClass = entityClass; + for (String fieldName : fieldNames) { + if (needNavigation(currentClass, fieldName, dictionary)) { + Type joinClass = dictionary.getParameterizedType(currentClass, fieldName); + elements.add(new PathElement(currentClass, joinClass, fieldName)); + currentClass = joinClass; + } else { + elements.add(resolvePathAttribute(currentClass, fieldName, + fieldName, Collections.emptySet(), dictionary)); + } + } + + return ImmutableList.copyOf(elements); + } + + protected PathElement resolvePathAttribute(Type entityClass, + String fieldName, + String alias, + Set arguments, + EntityDictionary dictionary) { + if (dictionary.isAttribute(entityClass, fieldName) + || fieldName.equals(dictionary.getIdFieldName(entityClass))) { + Type attributeClass = dictionary.getType(entityClass, fieldName); + return new PathElement(entityClass, attributeClass, fieldName, alias, arguments); + } + if ("this".equals(fieldName)) { + return new PathElement(entityClass, null, fieldName); + } + String entityAlias = dictionary.getJsonAliasFor(entityClass); + throw new InvalidValueException(entityAlias + " does not contain the field " + fieldName); + } + + /** + * 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(Type entityClass, String fieldName, EntityDictionary dictionary) { + return dictionary.isRelation(entityClass, fieldName) || dictionary.isComplexAttribute(entityClass, fieldName); + } + + public Optional lastElement() { + return pathElements.isEmpty() ? Optional.empty() : Optional.of(pathElements.get(pathElements.size() - 1)); + } + + public String getFieldPath() { + return pathElements.stream() + .map(PathElement::getFieldName) + .collect(Collectors.joining(PERIOD)); + } + + /** + * Returns an alias that uniquely identifies the last collection of entities in the path. + * @return An alias for the path. + */ + public String getAlias() { + if (pathElements.size() < 2) { + return lastElement() + .map(e -> getTypeAlias(e.getType())) + .orElse(null); + } + + PathElement previous = pathElements.get(pathElements.size() - 2); + return appendAlias(getTypeAlias(previous.getType()), previous.getFieldName()); + } + + @Override + public String toString() { + return CollectionUtils.isEmpty(pathElements) ? "EMPTY" + : pathElements.stream() + .map(e -> '[' + getSimpleName(e.getType()) + ']' + PERIOD + e.getFieldName()) + .collect(Collectors.joining("/")); + } +} 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 738634bc19..5457dcf2e3 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 @@ -1,66 +1,77 @@ /* - * Copyright 2016, Yahoo Inc. + * Copyright 2018, 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.annotation.LifeCycleHookBinding.Operation.CREATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.DELETE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; +import static com.yahoo.elide.core.dictionary.EntityBinding.EMPTY_BINDING; +import static com.yahoo.elide.core.dictionary.EntityDictionary.getType; +import static com.yahoo.elide.core.type.ClassType.COLLECTION_TYPE; + import com.yahoo.elide.annotation.Audit; import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.DeletePermission; -import com.yahoo.elide.annotation.OnCreate; -import com.yahoo.elide.annotation.OnDelete; -import com.yahoo.elide.annotation.OnUpdate; +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.core.audit.InvalidSyntaxException; +import com.yahoo.elide.core.audit.LogMessage; +import com.yahoo.elide.core.audit.LogMessageImpl; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityBinding; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.RelationshipType; +import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.exceptions.InternalServerErrorException; import com.yahoo.elide.core.exceptions.InvalidAttributeException; import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException; -import com.yahoo.elide.core.exceptions.InvalidPredicateException; -import com.yahoo.elide.core.filter.Operator; -import com.yahoo.elide.core.filter.Predicate; import com.yahoo.elide.core.filter.expression.AndFilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.filter.expression.PredicateExtractionVisitor; -import com.yahoo.elide.extensions.PatchRequestScope; +import com.yahoo.elide.core.filter.predicates.InPredicate; +import com.yahoo.elide.core.filter.visitors.VerifyFieldAccessFilterExpressionVisitor; +import com.yahoo.elide.core.request.Argument; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Pagination; +import com.yahoo.elide.core.request.Sorting; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.security.visitors.CanPaginateVisitor; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; 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.security.ChangeSpec; -import com.yahoo.elide.security.PermissionExecutor; -import com.yahoo.elide.security.User; -import com.yahoo.elide.security.permissions.ExpressionResult; -import com.yahoo.elide.utils.coerce.CoerceUtil; import com.fasterxml.jackson.annotation.JsonIgnore; import com.google.common.base.Preconditions; -import com.google.common.collect.Lists; +import com.google.common.base.Predicates; import com.google.common.collect.Sets; import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.lang3.text.WordUtils; +import org.apache.commons.collections4.IterableUtils; +import org.apache.commons.lang3.StringUtils; +import io.reactivex.Observable; import lombok.NonNull; -import lombok.extern.slf4j.Slf4j; import java.io.Serializable; import java.lang.annotation.Annotation; -import java.lang.reflect.AccessibleObject; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -73,329 +84,605 @@ import java.util.function.Supplier; import java.util.stream.Collectors; -import javax.persistence.GeneratedValue; - /** * Resource wrapper around Entity bean. * * @param type of resource */ -@Slf4j -public class PersistentResource implements com.yahoo.elide.security.PersistentResource { - private final String type; - protected T obj; - private final ResourceLineage lineage; - private final Optional uuid; - private final User user; - private final ObjectEntityCache entityCache; - - @Override - public String toString() { - String id = (uuid.isPresent()) ? uuid.get() : getId(); - return String.format("PersistentResource { type=%s, id=%s }", type, id); - } - - private final DataStoreTransaction transaction; - @NonNull private final RequestScope requestScope; - private final Optional> parent; - private int hashCode = 0; - +public class PersistentResource implements com.yahoo.elide.core.security.PersistentResource { + public static final Set ALL_FIELDS = null; + public static final String CLASS_NO_FIELD = ""; /** * The Dictionary. */ protected final EntityDictionary dictionary; - + private final Type type; + private final String typeName; + private final ResourceLineage lineage; + private final Optional uuid; + private final DataStoreTransaction transaction; + private final RequestScope requestScope; /* Sort strings first by length then contents */ - private final Comparator comparator = (string1, string2) -> { + private final Comparator lengthFirstComparator = (string1, string2) -> { int diff = string1.length() - string2.length(); return diff == 0 ? string1.compareTo(string2) : diff; }; + protected T obj; + private int hashCode = 0; /** - * Create a resource in the database. - * @param parent - The immediate ancestor in the lineage or null if this is a root. - * @param entityClass the entity class - * @param requestScope the request scope - * @param uuid the uuid - * @param object type - * @return persistent resource - */ - @SuppressWarnings("resource") - public static PersistentResource createObject( - PersistentResource parent, - Class entityClass, - RequestScope requestScope, - String uuid) { - DataStoreTransaction tx = requestScope.getTransaction(); - - T obj = tx.createObject(entityClass); - PersistentResource newResource = new PersistentResource<>(obj, parent, uuid, requestScope); - checkPermission(CreatePermission.class, newResource); - newResource.auditClass(Audit.Action.CREATE, new ChangeSpec(newResource, null, null, newResource.getObject())); - newResource.runTriggers(OnCreate.class); - requestScope.queueCommitTrigger(newResource); - - String type = newResource.getType(); - requestScope.getObjectEntityCache().put(type, uuid, newResource.getObject()); - - // Initialize null ToMany collections - requestScope.getDictionary().getRelationships(entityClass).stream() - .filter(relationName -> { - return newResource.getRelationshipType(relationName).isToMany() - && newResource.getValueUnchecked(relationName) == null; - }) - .forEach(relationName -> newResource.setValue(relationName, new LinkedHashSet<>())); - - // Keep track of new resources for non shareable resources - requestScope.getNewPersistentResources().add(newResource); - newResource.markDirty(); - return newResource; - } - - /** - * Create a resource in the database. - * @param entityClass the entity class - * @param requestScope the request scope - * @param uuid the uuid - * @param type of resource - * @return persistent resource - */ - public static PersistentResource createObject(Class entityClass, RequestScope requestScope, String uuid) { - return createObject(null, entityClass, requestScope, uuid); - } - - /** - * Constructor. - * - * @param parent the parent - * @param obj the obj - * @param requestScope the request scope - */ - public PersistentResource(PersistentResource parent, T obj, RequestScope requestScope) { - this(obj, parent, requestScope); - } - - /** - * Constructor. + * Construct a new resource from the ID provided. * - * @param obj the obj - * @param requestScope the request scope - */ - public PersistentResource(T obj, RequestScope requestScope) { - this(obj, null, requestScope); + * @param obj the obj + * @param parent the parent + * @param id the id + * @param parentRelationship The parent relationship traversed to this resource. + * @param scope the request scope + */ + public PersistentResource( + @NonNull T obj, + PersistentResource parent, + String parentRelationship, + String id, + @NonNull RequestScope scope + ) { + this.obj = obj; + this.type = getType(obj); + this.uuid = Optional.ofNullable(id); + this.lineage = parent != null + ? new ResourceLineage(parent.lineage, parent, parentRelationship) + : new ResourceLineage(); + this.dictionary = scope.getDictionary(); + this.typeName = dictionary.getJsonAliasFor(type); + this.transaction = scope.getTransaction(); + this.requestScope = scope; + dictionary.initializeEntity(obj); } /** * Construct a new resource from the ID provided. * - * @param obj the obj - * @param parent the parent - * @param id the id - * @param requestScope the request scope + * @param obj the obj + * @param id the id + * @param scope the request scope */ - protected PersistentResource(@NonNull T obj, PersistentResource parent, String id, RequestScope requestScope) { - this.obj = obj; - this.uuid = Optional.ofNullable(id); - // TODO Use Bind annotation - this.parent = Optional.ofNullable(parent); - this.lineage = this.parent.isPresent() ? new ResourceLineage(parent.lineage, parent) : new ResourceLineage(); - this.dictionary = requestScope.getDictionary(); - this.type = dictionary.getJsonAliasFor(obj.getClass()); - this.user = requestScope.getUser(); - this.entityCache = requestScope.getObjectEntityCache(); - this.transaction = requestScope.getTransaction(); - this.requestScope = requestScope; - dictionary.initializeEntity(obj); + public PersistentResource( + @NonNull T obj, + String id, + @NonNull RequestScope scope + ) { + this(obj, null, null, id, scope); } /** - * Constructor for testing. + * Create a resource in the database. * - * @param obj the obj - * @param parent the parent + * @param entityClass the entity class * @param requestScope the request scope + * @param uuid the (optional) uuid + * @param object type + * @return persistent resource */ - protected PersistentResource(T obj, PersistentResource parent, RequestScope requestScope) { - this(obj, parent, requestScope.getObjectEntityCache().getUUID(obj), requestScope); + public static PersistentResource createObject( + Type entityClass, + RequestScope requestScope, + Optional uuid) { + return createObject(null, null, entityClass, requestScope, uuid); } /** - * Check whether an id matches for this persistent resource. + * Create a resource in the database. * - * @param checkId the check id - * @return True if matches false otherwise + * @param parent - The immediate ancestor in the lineage or null if this is a root. + * @param parentRelationship - The name of the parent relationship traversed to create this object. + * @param entityClass the entity class + * @param requestScope the request scope + * @param uuid the (optional) uuid + * @param object type + * @return persistent resource */ - public boolean matchesId(String checkId) { - if (checkId == null) { - return false; - } else if (uuid.isPresent() && checkId.equals(uuid.get())) { - return true; - } - String id = getId(); - return !id.equals("0") && checkId.equals(id); + public static PersistentResource createObject( + PersistentResource parent, + String parentRelationship, + Type entityClass, + RequestScope requestScope, + Optional uuid) { + + T obj = requestScope.getTransaction().createNewObject(entityClass, requestScope); + + String id = uuid.orElse(null); + + PersistentResource newResource = new PersistentResource<>(obj, parent, parentRelationship, id, requestScope); + + //The ID must be assigned before we add it to the new resources set. Persistent resource + //hashcode and equals are only based on the ID/UUID & type. + assignId(newResource, id); + + // 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, CREATE); + + requestScope.setUUIDForObject(newResource.type, id, newResource.getObject()); + + // Initialize null ToMany collections + requestScope.getDictionary().getRelationships(entityClass).stream() + .filter(relationName -> newResource.getRelationshipType(relationName).isToMany() + && newResource.getValueUnchecked(relationName) == null) + .forEach(relationName -> newResource.setValue(relationName, new LinkedHashSet<>())); + + newResource.markDirty(); + return newResource; } /** * Load an single entity from the DB. * - * @param loadClass resource type - * @param id the id + * @param projection What to load from the DB. + * @param id the id * @param requestScope the request scope - * @param type of resource + * @param type of resource * @return resource persistent resource * @throws InvalidObjectIdentifierException the invalid object identifier exception */ @SuppressWarnings("resource") - @NonNull public static PersistentResource loadRecord( - Class loadClass, String id, RequestScope requestScope) - throws InvalidObjectIdentifierException { - Preconditions.checkNotNull(loadClass); + @NonNull + public static PersistentResource loadRecord( + 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(); - ObjectEntityCache cache = requestScope.getObjectEntityCache(); + Type loadClass = projection.getType(); // Check the resource cache if exists - @SuppressWarnings("unchecked") - T obj = (T) cache.get(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.getRequestedFields()); + Type idType = dictionary.getIdType(loadClass); + + 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, loadClass.getSimpleName()); + throw new InvalidObjectIdentifierException(id, dictionary.getJsonAliasFor(loadClass)); } } - PersistentResource resource = new PersistentResource<>(obj, requestScope); + PersistentResource resource = new PersistentResource<>( + (T) obj, + requestScope.getUUIDFor(obj), + requestScope); + // No need to have read access for a newly created object if (!requestScope.getNewResources().contains(resource)) { - resource.checkFieldAwarePermissions(ReadPermission.class); + resource.checkFieldAwarePermissions(ReadPermission.class, projection.getRequestedFields()); } - requestScope.queueCommitTrigger(resource); return resource; } + /** + * Get a FilterExpression parsed from FilterExpressionCheck. + * + * @param the type parameter + * @param loadClass the load class + * @param requestScope the request scope + * @param requestedFields The set of requested fields + * @return a FilterExpression defined by FilterExpressionCheck. + */ + private static Optional getPermissionFilterExpression(Type loadClass, + RequestScope requestScope, + Set requestedFields) { + try { + return requestScope.getPermissionExecutor().getReadPermissionFilter(loadClass, requestedFields); + } catch (ForbiddenAccessException e) { + return Optional.empty(); + } + } + /** * Load a collection from the datastore. * - * @param the type parameter - * @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. */ - @NonNull public static Set> loadRecords(Class loadClass, RequestScope requestScope) { + public static Observable loadRecords( + EntityProjection projection, + List ids, + RequestScope requestScope) { + + Type loadClass = projection.getType(); + Pagination pagination = projection.getPagination(); + Sorting sorting = projection.getSorting(); + + FilterExpression filterExpression = projection.getFilterExpression(); + + EntityDictionary dictionary = requestScope.getDictionary(); + DataStoreTransaction tx = requestScope.getTransaction(); - if (shouldSkipCollection(loadClass, ReadPermission.class, requestScope)) { - return Collections.emptySet(); + if (shouldSkipCollection(loadClass, ReadPermission.class, requestScope, projection.getRequestedFields())) { + if (ids.isEmpty()) { + return Observable.empty(); + } + throw new InvalidObjectIdentifierException(ids.toString(), dictionary.getJsonAliasFor(loadClass)); } - Iterable list; - FilterScope filterScope = new FilterScope(requestScope, loadClass); - list = tx.loadObjects(loadClass, filterScope); - Set> resources = new PersistentResourceSet(list, requestScope); - resources = filter(ReadPermission.class, resources); - for (PersistentResource resource : resources) { - requestScope.queueCommitTrigger(resource); + Set requestedFields = projection.getRequestedFields(); + + if (pagination != null && !pagination.isDefaultInstance() + && !CanPaginateVisitor.canPaginate(loadClass, dictionary, requestScope, requestedFields)) { + throw new BadRequestException(String.format("Cannot paginate %s", + dictionary.getJsonAliasFor(loadClass))); } - return resources; + + Set newResources = new LinkedHashSet<>(); + + if (!ids.isEmpty()) { + String typeAlias = dictionary.getJsonAliasFor(loadClass); + newResources = requestScope.getNewPersistentResources().stream() + .filter(resource -> typeAlias.equals(resource.getTypeName()) + && ids.contains(resource.getUUID().orElse(""))) + .collect(Collectors.toSet()); + FilterExpression idExpression = buildIdFilterExpression(ids, loadClass, dictionary, requestScope); + + // Combine filters if necessary + filterExpression = Optional.ofNullable(filterExpression) + .map(fe -> (FilterExpression) new AndFilterExpression(idExpression, fe)) + .orElse(idExpression); + } + + Optional permissionFilter = getPermissionFilterExpression(loadClass, requestScope, + requestedFields); + if (permissionFilter.isPresent()) { + if (filterExpression != null) { + filterExpression = new AndFilterExpression(filterExpression, permissionFilter.get()); + } else { + filterExpression = permissionFilter.get(); + } + } + + EntityProjection modifiedProjection = projection + .copyOf() + .filterExpression(filterExpression) + .sorting(sorting) + .pagination(pagination) + .build(); + + Observable existingResources = filter( + ReadPermission.class, + Optional.ofNullable(modifiedProjection.getFilterExpression()), + projection.getRequestedFields(), + Observable.fromIterable( + new PersistentResourceSet(tx.loadObjects(modifiedProjection, requestScope), requestScope)) + ); + + // TODO: Sort again in memory now that two sets are glommed together? + Observable allResources = + Observable.fromIterable(newResources).mergeWith(existingResources); + + Set foundIds = new HashSet<>(); + + allResources = allResources.doOnNext((resource) -> { + String id = (String) resource.getUUID().orElseGet(resource::getId); + if (ids.contains(id)) { + foundIds.add(id); + } + }); + + allResources = allResources.doOnComplete(() -> { + Set missedIds = Sets.difference(new HashSet<>(ids), foundIds); + if (!missedIds.isEmpty()) { + throw new InvalidObjectIdentifierException(missedIds.toString(), dictionary.getJsonAliasFor(loadClass)); + } + }); + + return allResources; } /** - * Get a FilterExpression parsed from FilterExpressionCheck. + * Build an id filter expression for a particular entity type. * - * @param the type parameter - * @param loadClass the load class - * @param requestScope the request scope - * @return a FilterExpression defined by FilterExpressionCheck. + * @param ids Ids to include in the filter expression + * @param entityType Type of entity + * @return Filter expression for given ids and type. + */ + private static FilterExpression buildIdFilterExpression(List ids, + Type entityType, + EntityDictionary dictionary, + RequestScope scope) { + Type idType = dictionary.getIdType(entityType); + String idField = dictionary.getIdFieldName(entityType); + + List coercedIds = ids.stream() + .filter(id -> scope.getObjectById(entityType, id) == null) // these don't exist yet + .map(id -> CoerceUtil.coerce(id, idType)) + .collect(Collectors.toList()); + + /* construct a new SQL like filter expression, eg: book.id IN [1,2] */ + FilterExpression idFilter = new InPredicate( + new Path.PathElement( + entityType, + idType, + idField), + coercedIds); + + return idFilter; + } + + /** + * Determine whether or not to skip loading a collection. + * + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param requestedFields The set of requested fields + * @param requestScope Request scope + * @return True if collection should be skipped (i.e. denied access), false otherwise */ - private static Optional getPermissionFilterExpression(Class loadClass, - RequestScope requestScope) { + private static boolean shouldSkipCollection(Type resourceClass, Class annotationClass, + RequestScope requestScope, Set requestedFields) { try { - return requestScope.getPermissionExecutor().getReadPermissionFilter(loadClass); + requestScope.getPermissionExecutor().checkUserPermissions(resourceClass, annotationClass, requestedFields); + return false; } catch (ForbiddenAccessException e) { - return Optional.empty(); + return true; } } /** - * Load a collection from the datastore. + * Invoke the get[fieldName] method on the target object OR get the field with the corresponding name. * - * @param the type parameter - * @param loadClass the load class + * @param target the object to get + * @param fieldName the field name to get or invoke equivalent get method * @param requestScope the request scope - * @return a filtered collection of resources loaded from the datastore. + * @return the value */ - @NonNull public static Set> loadRecordsWithSortingAndPagination( - Class loadClass, RequestScope requestScope) { - DataStoreTransaction tx = requestScope.getTransaction(); + public static Object getValue(Object target, String fieldName, RequestScope requestScope) { + return requestScope.getDictionary().getValue(target, fieldName, requestScope); + } - if (shouldSkipCollection(loadClass, ReadPermission.class, requestScope)) { - return Collections.emptySet(); - } + /** + * Filter a set of PersistentResources. + * Verify fields have ReadPermission on filter join. + * + * @param permission the permission + * @param resources the resources + * @return Filtered set of resources + */ + protected static Observable filter(Class permission, + Optional filter, + Set requestedFields, + Observable resources) { - EntityDictionary dictionary = requestScope.getDictionary(); - if (! requestScope.getPagination().isDefaultInstance() - && !CanPaginateVisitor.canPaginate(loadClass, dictionary, requestScope)) { - throw new InvalidPredicateException(String.format("Cannot paginate %s", - dictionary.getJsonAliasFor(loadClass))); - } + return resources.filter(resource -> { + try { + // NOTE: This is for avoiding filtering on _newly created_ objects within this transaction. + // Namely-- in a JSONPATCH request or GraphQL request-- we need to read all newly created + // resources /regardless/ of whether or not we actually have permission to do so; this is to + // retrieve the object id to return to the caller. If no fields on the object are readable by the caller + // then they will be filtered out and only the id is returned. Similarly, all future requests to this + // object will behave as expected. + if (!resource.getRequestScope().getNewResources().contains(resource)) { + resource.checkFieldAwarePermissions(permission, requestedFields); + // Verify fields have ReadPermission on filter join + return !filter.isPresent() + || filter.get().accept(new VerifyFieldAccessFilterExpressionVisitor(resource)); + } + return true; + } catch (ForbiddenAccessException e) { + return false; + } + }); + } + + private static ExpressionResult checkPermission( + Class annotationClass, PersistentResource resource) { + return resource.requestScope.getPermissionExecutor().checkPermission(annotationClass, resource); + } - Iterable list; - FilterScope filterScope = new FilterScope(requestScope, loadClass); - list = tx.loadObjectsWithSortingAndPagination(loadClass, filterScope); - Set> resources = new PersistentResourceSet(list, requestScope); - resources = filter(ReadPermission.class, resources); - for (PersistentResource resource : resources) { - requestScope.queueCommitTrigger(resource); + private static ExpressionResult checkUserPermission( + Class annotationClass, Object obj, RequestScope requestScope, Set requestedFields) { + return requestScope.getPermissionExecutor() + .checkUserPermissions(getType(obj), annotationClass, requestedFields); + } + + protected static boolean checkIncludeSparseField(Map> sparseFields, String type, + String fieldName) { + if (!sparseFields.isEmpty()) { + if (!sparseFields.containsKey(type)) { + return false; + } + + return sparseFields.get(type).contains(fieldName); } - return resources; + + return true; } /** - * Gets the total number of records that could be loaded from the data store ignoring any pagination limits + * Assign provided id if id field is not generated. * - * @param the type parameter - * @param loadClass the load class - * @param requestScope the request scope - * @return total number of records that could be loaded + * @param persistentResource resource + * @param id resource id */ - public static Long getTotalRecords(Class loadClass, RequestScope requestScope) { - DataStoreTransaction tx = requestScope.getTransaction(); + private static void assignId(PersistentResource persistentResource, String id) { - if (shouldSkipCollection(loadClass, ReadPermission.class, requestScope)) { - return 0L; + //If id field is not a `@GeneratedValue` or mapped via a `@MapsId` attribute + //then persist the provided id + if (!persistentResource.isIdGenerated()) { + if (StringUtils.isNotEmpty(id)) { + persistentResource.setId(id); + } else { + //If expecting id to persist and id is not present, throw exception + throw new BadRequestException( + "No id provided, cannot persist " + persistentResource.getTypeName()); + } } + } + + private static T firstOrNullIfEmpty(final Collection coll) { + return CollectionUtils.isEmpty(coll) ? null : IterableUtils.first(coll); + } + + public static T firstOrNullIfEmpty(final Observable coll) { + return firstOrNullIfEmpty(coll.toList().blockingGet()); + } + + @Override + public String toString() { + return String.format("PersistentResource{type=%s, id=%s}", typeName, uuid.orElseGet(this::getId)); + } - return tx.getTotalRecords(loadClass); + /** + * Check whether an id matches for this persistent resource. + * + * @param checkId the check id + * @return True if matches false otherwise + */ + @Override + public boolean matchesId(String checkId) { + if (checkId == null) { + return false; + } + return uuid + .map(checkId::equals) + .orElseGet(() -> { + String id = getId(); + return !"0".equals(id) && !"null".equals(id) && checkId.equals(id); + }); } /** * Update attribute in existing resource. * * @param fieldName the field name - * @param newVal the new val + * @param newVal the new val * @return true if object updated, false otherwise */ public boolean updateAttribute(String fieldName, Object newVal) { - Class fieldClass = dictionary.getType(getResourceClass(), fieldName); - newVal = coerce(newVal, fieldName, fieldClass); + Type fieldClass = dictionary.getType(getResourceType(), fieldName); + final Object coercedNewValue = dictionary.coerce(obj, newVal, fieldName, fieldClass); Object val = getValueUnchecked(fieldName); - checkFieldAwareDeferPermissions(UpdatePermission.class, fieldName, newVal, val); - if (val != newVal && (val == null || !val.equals(newVal))) { - this.setValueChecked(fieldName, newVal); + checkFieldAwareDeferPermissions(UpdatePermission.class, fieldName, coercedNewValue, val); + if (!Objects.equals(val, coercedNewValue)) { + if (val == null + || coercedNewValue == null + || !dictionary.isComplexAttribute(getType(obj), fieldName)) { + this.setValueChecked(fieldName, coercedNewValue); + } else { + if (newVal instanceof Map) { + + //We perform a copy here for two reasons: + //1. We want the original so we can dispatch update life cycle hooks. + //2. Some stores (Hibernate) won't notice changes to an attribute if the attribute + //has a @TypeDef annotation unless we modify the reference in the parent object. This rules + //out an update in place strategy. + Object copy = copyComplexAttribute(val); + + //Update the copy. + this.updateComplexAttribute(dictionary, (Map) newVal, copy, requestScope); + + //Set the copy. + dictionary.setValue(obj, fieldName, copy); + triggerUpdate(fieldName, val, copy); + } else { + this.setValueChecked(fieldName, coercedNewValue); + } + } this.markDirty(); + //Hooks for customize logic for setAttribute/Relation + if (dictionary.isAttribute(getType(obj), fieldName)) { + transaction.setAttribute(obj, Attribute.builder() + .name(fieldName) + .type(fieldClass) + .argument(Argument.builder() + .name("_UNUSED_") + .value(newVal).build()) + .build(), requestScope); + } return true; } return false; } + private void updateComplexAttribute(EntityDictionary dictionary, + Map updateValue, + Object currentValue, + RequestScope scope) { + for (String field : updateValue.keySet()) { + final Object newValue = updateValue.get(field); + final Object coercedNewValue = + dictionary.coerce(currentValue, newValue, field, dictionary.getType(currentValue, field)); + final Object newOriginal = dictionary.getValue(currentValue, field, scope); + if (!Objects.equals(newOriginal, coercedNewValue)) { + if (newOriginal == null + || coercedNewValue == null + || !dictionary.isComplexAttribute(ClassType.of(currentValue.getClass()), field)) { + dictionary.setValue(currentValue, field, coercedNewValue); + } else { + if (newValue instanceof Map) { + this.updateComplexAttribute(dictionary, (Map) newValue, newOriginal, scope); + } else { + dictionary.setValue(currentValue, field, coercedNewValue); + } + } + } + } + } + + /** + * Copies a complex attribute. If the attribute fields are complex, recurses to perform a deep copy. + * @param object The attribute to copy. + * @return The copy. + */ + private Object copyComplexAttribute(Object object) { + if (object == null) { + return null; + } + + Type type = getType(object); + EntityBinding binding = dictionary.getEntityBinding(type); + + Preconditions.checkState(! binding.equals(EMPTY_BINDING), "Model not found."); + Preconditions.checkState(binding.apiRelationships.isEmpty(), "Deep copy of relationships not supported"); + + Object copy; + try { + copy = type.newInstance(); + } catch (InstantiationException | IllegalAccessException e) { + throw new IllegalStateException("Cannot perform deep copy of " + type.getName(), e); + } + + binding.apiAttributes.forEach(attribute -> { + Object newValue; + Object oldValue = dictionary.getValue(object, attribute, requestScope); + if (! dictionary.isComplexAttribute(type, attribute)) { + newValue = oldValue; + } else { + newValue = copyComplexAttribute(oldValue); + } + dictionary.setValue(copy, attribute, newValue); + }); + + return copy; + } + /** * Perform a full replacement on relationships. * Here is an example: @@ -412,41 +699,49 @@ public boolean updateAttribute(String fieldName, Object newVal) { * updated = (requested UNION mine) - (requested INTERSECT mine) * deleted = (mine - requested) * final = (notMine) UNION requested - * @param fieldName the field name + * + * @param fieldName the field name * @param resourceIdentifiers the resource identifiers * @return True if object updated, false otherwise */ public boolean updateRelation(String fieldName, Set resourceIdentifiers) { RelationshipType type = getRelationshipType(fieldName); + Set resources = filter( ReadPermission.class, - (Set) getRelationUncheckedUnfiltered(fieldName) - ); + Optional.empty(), + ALL_FIELDS, + getRelationUncheckedUnfiltered(fieldName)).toList(LinkedHashSet::new).blockingGet(); + + boolean isUpdated; if (type.isToMany()) { + List modifiedResources = CollectionUtils.isEmpty(resourceIdentifiers) + ? Collections.emptyList() + : resourceIdentifiers.stream().map(PersistentResource::getObject).collect(Collectors.toList()); checkFieldAwareDeferPermissions( UpdatePermission.class, fieldName, - resourceIdentifiers.stream().map(PersistentResource::getObject).collect(Collectors.toList()), + modifiedResources, resources.stream().map(PersistentResource::getObject).collect(Collectors.toList()) ); - return updateToManyRelation(fieldName, resourceIdentifiers, resources); + isUpdated = updateToManyRelation(fieldName, resourceIdentifiers, resources); } else { // To One Relationship - PersistentResource resource = (resources.isEmpty()) ? null : resources.iterator().next(); + PersistentResource resource = firstOrNullIfEmpty(resources); Object original = (resource == null) ? null : resource.getObject(); - PersistentResource modifiedResource = - (resourceIdentifiers == null || resourceIdentifiers.isEmpty()) ? null - : resourceIdentifiers.iterator().next(); + PersistentResource modifiedResource = firstOrNullIfEmpty(resourceIdentifiers); Object modified = (modifiedResource == null) ? null : modifiedResource.getObject(); checkFieldAwareDeferPermissions(UpdatePermission.class, fieldName, modified, original); - return updateToOneRelation(fieldName, resourceIdentifiers, resources); + isUpdated = updateToOneRelation(fieldName, resourceIdentifiers, resources); } + return isUpdated; } /** * Updates a to-many relationship. - * @param fieldName the field name + * + * @param fieldName the field name * @param resourceIdentifiers the resource identifiers - * @param mine Existing, filtered relationships for field name + * @param mine Existing, filtered relationships for field name * @return true if updated. false otherwise */ protected boolean updateToManyRelation(String fieldName, @@ -480,42 +775,43 @@ protected boolean updateToManyRelation(String fieldName, added = Sets.difference(updated, deleted); - checkSharePermission(added); + checkTransferablePermission(added); - Collection collection = (Collection) this.getValueUnchecked(fieldName); - - if (collection == null) { - this.setValue(fieldName, mine); - } + Set newRelationships = new LinkedHashSet<>(); + Set deletedRelationships = new LinkedHashSet<>(); deleted .stream() .forEach(toDelete -> { - delFromCollection(collection, fieldName, toDelete, false); - deleteInverseRelation(fieldName, toDelete.getObject()); + deletedRelationships.add(toDelete.getObject()); }); added .stream() .forEach(toAdd -> { - addToCollection(collection, fieldName, toAdd); - addInverseRelation(fieldName, toAdd.getObject()); + newRelationships.add(toAdd.getObject()); }); + Collection collection = (Collection) this.getValueUnchecked(fieldName); + modifyCollection(collection, fieldName, newRelationships, deletedRelationships, true); if (!updated.isEmpty()) { this.markDirty(); } + //hook for updateRelation + transaction.updateToManyRelation(transaction, obj, fieldName, + newRelationships, deletedRelationships, requestScope); + return !updated.isEmpty(); } /** * Update a 2-one relationship. * - * @param fieldName the field name + * @param fieldName the field name * @param resourceIdentifiers the resource identifiers - * @param mine Existing, filtered relationships for field name + * @param mine Existing, filtered relationships for field name * @return true if updated. false otherwise */ protected boolean updateToOneRelation(String fieldName, @@ -523,29 +819,29 @@ protected boolean updateToOneRelation(String fieldName, Set mine) { Object newValue = null; PersistentResource newResource = null; - if (resourceIdentifiers != null && !resourceIdentifiers.isEmpty()) { - newResource = resourceIdentifiers.iterator().next(); + if (CollectionUtils.isNotEmpty(resourceIdentifiers)) { + newResource = IterableUtils.first(resourceIdentifiers); newValue = newResource.getObject(); } - PersistentResource oldResource = !mine.isEmpty() ? mine.iterator().next() : null; + PersistentResource oldResource = firstOrNullIfEmpty(mine); if (oldResource == null) { 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(); } } - if (newValue != null) { + if (newResource != null) { if (hasInverseRelation(fieldName)) { addInverseRelation(fieldName, newValue); newResource.markDirty(); @@ -553,6 +849,8 @@ protected boolean updateToOneRelation(String fieldName, } this.setValueChecked(fieldName, newValue); + //hook for updateToOneRelation + transaction.updateToOneRelation(transaction, obj, fieldName, newValue, requestScope); this.markDirty(); return true; @@ -565,8 +863,11 @@ protected boolean updateToOneRelation(String fieldName, * @return True if object updated, false otherwise */ public boolean clearRelation(String relationName) { - Set mine = filter(ReadPermission.class, (Set) getRelationUncheckedUnfiltered(relationName)); - checkFieldAwarePermissions(UpdatePermission.class, relationName, Collections.emptySet(), + Set mine = filter(ReadPermission.class, Optional.empty(), + ALL_FIELDS, + getRelationUncheckedUnfiltered(relationName)).toList(LinkedHashSet::new).blockingGet(); + + checkFieldAwareDeferPermissions(UpdatePermission.class, relationName, Collections.emptySet(), mine.stream().map(PersistentResource::getObject).collect(Collectors.toSet())); if (mine.isEmpty()) { @@ -575,42 +876,39 @@ public boolean clearRelation(String relationName) { RelationshipType type = getRelationshipType(relationName); - mine.stream() - .forEach(toDelete -> { - if (hasInverseRelation(relationName)) { - deleteInverseRelation(relationName, toDelete.getObject()); - toDelete.markDirty(); - } - }); - if (type.isToOne()) { - PersistentResource oldValue = mine.iterator().next(); + PersistentResource oldValue = IterableUtils.first(mine); if (oldValue != null && oldValue.getObject() != null) { this.nullValue(relationName, oldValue); oldValue.markDirty(); this.markDirty(); + //hook for updateToOneRelation + transaction.updateToOneRelation(transaction, obj, relationName, null, requestScope); + } } else { Collection collection = (Collection) getValueUnchecked(relationName); - if (collection != null && !collection.isEmpty()) { + if (CollectionUtils.isNotEmpty(collection)) { + Set deletedRelationships = new LinkedHashSet<>(); mine.stream() .forEach(toDelete -> { - delFromCollection(collection, relationName, toDelete, false); - if (hasInverseRelation(relationName)) { - toDelete.markDirty(); - } + deletedRelationships.add(toDelete.getObject()); }); + modifyCollection(collection, relationName, Collections.emptySet(), deletedRelationships, true); this.markDirty(); + //hook for updateToManyRelation + transaction.updateToManyRelation(transaction, obj, relationName, + new LinkedHashSet<>(), deletedRelationships, requestScope); + } } - return true; } /** * Remove a relationship. * - * @param fieldName the field name + * @param fieldName the field name * @param removeResource the remove resource */ public void removeRelation(String fieldName, PersistentResource removeResource) { @@ -629,53 +927,69 @@ public void removeRelation(String fieldName, PersistentResource removeResource) ); } - checkFieldAwarePermissions(UpdatePermission.class, fieldName, modified, original); + checkFieldAwareDeferPermissions(UpdatePermission.class, fieldName, modified, original); if (relation instanceof Collection) { - if (!((Collection) relation).contains(removeResource.getObject())) { + if (removeResource == null || !((Collection) relation).contains(removeResource.getObject())) { //Nothing to do return; } - delFromCollection((Collection) relation, fieldName, removeResource, false); + modifyCollection((Collection) relation, fieldName, Collections.emptySet(), + Set.of(removeResource.getObject()), true); } else { - if (relation == null || !relation.equals(removeResource.getObject())) { + if (relation == null || removeResource == null || !relation.equals(removeResource.getObject())) { //Nothing to do return; } this.nullValue(fieldName, removeResource); - } - if (hasInverseRelation(fieldName)) { - deleteInverseRelation(fieldName, removeResource.getObject()); - removeResource.markDirty(); + if (hasInverseRelation(fieldName)) { + deleteInverseRelation(fieldName, removeResource.getObject()); + removeResource.markDirty(); + } } - if (original != modified && original != null && !original.equals(modified)) { + if (!Objects.equals(original, modified)) { this.markDirty(); } + + RelationshipType type = getRelationshipType(fieldName); + if (type.isToOne()) { + //hook for updateToOneRelation + transaction.updateToOneRelation(transaction, obj, fieldName, null, requestScope); + } else { + //hook for updateToManyRelation + transaction.updateToManyRelation(transaction, obj, fieldName, + new LinkedHashSet<>(), Sets.newHashSet(removeResource.getObject()), requestScope); + } } /** * Add relation link from a given parent resource to a child resource. * - * @param fieldName which relation link + * @param fieldName which relation link * @param newRelation the new relation */ public void addRelation(String fieldName, PersistentResource newRelation) { - checkSharePermission(Collections.singleton(newRelation)); + checkTransferablePermission(Collections.singleton(newRelation)); Object relation = this.getValueUnchecked(fieldName); if (relation instanceof Collection) { - if (addToCollection((Collection) relation, fieldName, newRelation)) { + if (modifyCollection((Collection) relation, fieldName, + Set.of(newRelation.getObject()), Collections.emptySet(), true)) { this.markDirty(); + + //Hook for updateToManyRelation + transaction.updateToManyRelation(transaction, obj, fieldName, + Sets.newHashSet(newRelation.getObject()), new LinkedHashSet<>(), requestScope); + + addInverseRelation(fieldName, newRelation.getObject()); } - addInverseRelation(fieldName, newRelation.getObject()); } else { // Not a collection, but may be trying to create a ToOne relationship. // NOTE: updateRelation marks dirty. updateRelation(fieldName, Collections.singleton(newRelation)); - return; } } @@ -684,7 +998,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; } @@ -692,10 +1006,26 @@ protected void checkSharePermission(Set resourceIdentifiers) final Set newResources = getRequestScope().getNewPersistentResources(); for (PersistentResource persistentResource : resourceIdentifiers) { - if (!newResources.contains(persistentResource) - && !lineage.getRecord(persistentResource.getType()).contains(persistentResource)) { - checkPermission(SharePermission.class, persistentResource); + + //New resources are exempt from NonTransferable checks + if (newResources.contains(persistentResource) + //This allows nested object hierarchies of non-transferables that are created in more than one + //client request. A & B are created in one request and C is created in a subsequent request). + //Even though B is non-transferable, while creating C in /A/B, C is allowed to + //reference B because B & C are part of the same non-transferable object hierarchy. + //To do this, the client must be able to read B (since they navigated through it) and also + //update the relationship that links B & C. + + //The object being added (C) is non-transferable + || (!dictionary.isTransferable(getResourceType()) + //The object being added to (B) is not strict + && !dictionary.isStrictNonTransferable(persistentResource.getResourceType()) + //B is in C's lineage (/B/C). + && persistentResource.equals(lineage.getParent()))) { + continue; } + + checkPermission(NonTransferable.class, persistentResource); } } @@ -706,17 +1036,23 @@ protected void checkSharePermission(Set resourceIdentifiers) */ public void deleteResource() throws ForbiddenAccessException { checkPermission(DeletePermission.class, this); - /* * Search for bidirectional relationships. For each bidirectional relationship, * we need to remove ourselves from that relationship */ - Map relations = getRelationships(); - for (Map.Entry entry : relations.entrySet()) { - String relationName = entry.getKey(); - String inverseRelationName = dictionary.getRelationInverse(getResourceClass(), relationName); - if (!inverseRelationName.equals("")) { - for (PersistentResource inverseResource : getRelationCheckedFiltered(relationName)) { + + Type resourceClass = getResourceType(); + List relationships = dictionary.getRelationships(resourceClass); + for (String relationName : relationships) { + + /* Skip updating inverse relationships for deletes which are cascaded */ + if (dictionary.cascadeDeletes(resourceClass, relationName)) { + continue; + } + String inverseRelationName = dictionary.getRelationInverse(resourceClass, relationName); + if (!"".equals(inverseRelationName)) { + for (PersistentResource inverseResource : getRelationUncheckedUnfiltered(relationName) + .toList().blockingGet()) { if (hasInverseRelation(relationName)) { deleteInverseRelation(relationName, inverseResource.getObject()); inverseResource.markDirty(); @@ -725,9 +1061,10 @@ public void deleteResource() throws ForbiddenAccessException { } } - transaction.delete(getObject()); + transaction.delete(getObject(), requestScope); auditClass(Audit.Action.DELETE, new ChangeSpec(this, null, getObject(), null)); - runTriggers(OnDelete.class); + requestScope.publishLifecycleEvent(this, DELETE); + requestScope.getDeletedResources().add(this); } /** @@ -735,6 +1072,7 @@ public void deleteResource() throws ForbiddenAccessException { * * @return ID id */ + @Override public String getId() { return dictionary.getId(getObject()); } @@ -745,7 +1083,7 @@ public String getId() { * @param id resource id */ public void setId(String id) { - this.setValue(dictionary.getIdFieldName(getResourceClass()), id); + dictionary.setId(obj, id); } /** @@ -754,335 +1092,265 @@ public void setId(String id) { * @return Boolean */ public boolean isIdGenerated() { - return getIdAnnotations().stream().anyMatch(a -> a.annotationType().equals(GeneratedValue.class)); - } - - /** - * Returns annotations applied to the ID field. - * - * @return Collection of Annotations - */ - private Collection getIdAnnotations() { - return dictionary.getIdAnnotations(getObject()); + return dictionary.getEntityBinding(type).isIdGenerated(); } /** * Gets UUID. + * * @return the UUID */ + @Override public Optional getUUID() { return uuid; } + /** + * Get relation looking for a _single_ id. + *

+ * NOTE: Filter expressions for this type are _not_ applied at this level. + * + * @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(com.yahoo.elide.core.request.Relationship relationship, String id) { + List resources = + getRelation(Collections.singletonList(id), relationship).toList().blockingGet(); + + 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; + } /** - * Load a single entity relation from the PersistentResource. + * Load a relation from the PersistentResource. * - * @param relation the relation - * @param id the id + * @param relationship the relation + * @param ids a list of object identifiers to optionally load. Can be empty. * @return PersistentResource relation */ - public PersistentResource getRelation(String relation, String id) { - - Optional filterExpression; - boolean skipNew = false; - // Criteria filtering not supported in Patch extension - if (requestScope instanceof PatchRequestScope) { - filterExpression = Optional.empty(); - // NOTE: We can safely _skip_ tests here since we are only skipping READ checks on - // NEWLY created objects. We assume a user can READ their object in the midst of creation. - // Imposing a constraint to the contrary-- at this moment-- seems arbitrary and does not - // reflect reality (i.e. if a user is creating an object with values, he/she knows those values - // already). - skipNew = true; - } else { - Class entityType = dictionary.getParameterizedType(getResourceClass(), relation); - if (entityType == null) { - throw new InvalidAttributeException(relation, type); - } - Class idType = dictionary.getIdType(entityType); - Object idVal = CoerceUtil.coerce(id, idType); - String idField = dictionary.getIdFieldName(entityType); - - List path = Lists.newArrayList( - new Predicate.PathElement( - getResourceClass(), - getType(), - entityType, - relation - ), - new Predicate.PathElement( - entityType, - relation, - idType, - idField - ) - ); + public Observable getRelation(List ids, + com.yahoo.elide.core.request.Relationship relationship) { + + FilterExpression filterExpression = Optional.ofNullable(relationship.getProjection().getFilterExpression()) + .orElse(null); + + assertPropertyExists(relationship.getName()); + Type entityType = dictionary.getParameterizedType(getResourceType(), relationship.getName()); - filterExpression = Optional.of(new Predicate( - path, - Operator.IN, - Collections.singletonList(idVal))); + Set newResources = new LinkedHashSet<>(); + + /* If this is a bulk edit request and the ID we are fetching for is newly created... */ + 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() + .filter(resource -> entityType.isAssignableFrom(resource.getResourceType()) + && ids.contains(resource.getUUID().orElse(""))) + .collect(Collectors.toSet()); + + FilterExpression idExpression = buildIdFilterExpression(ids, entityType, dictionary, requestScope); + + // Combine filters if necessary + filterExpression = Optional.ofNullable(relationship.getProjection().getFilterExpression()) + .map(fe -> (FilterExpression) new AndFilterExpression(idExpression, fe)) + .orElse(idExpression); } - Set resources = - filter(ReadPermission.class, - (Set) getRelationChecked(relation, filterExpression), - skipNew); + // TODO: Filter on new resources? + // TODO: Update pagination to subtract the number of new resources created? - for (PersistentResource childResource : resources) { - if (childResource.matchesId(id)) { - return childResource; + Observable existingResources = filter( + ReadPermission.class, + Optional.ofNullable(filterExpression), + relationship.getProjection().getRequestedFields(), + getRelation(relationship.copyOf() + .projection(relationship.getProjection().copyOf() + .filterExpression(filterExpression) + .build()) + .build(), true)); + + // TODO: Sort again in memory now that two sets are glommed together? + Observable allResources = + Observable.fromIterable(newResources).mergeWith(existingResources); + + Set foundIds = new HashSet<>(); + + allResources = allResources.doOnNext((resource) -> { + String id = (String) (resource.getUUID().orElseGet(resource::getId)); + if (ids.contains(id)) { + foundIds.add(id); } - } - throw new InvalidObjectIdentifierException(id, relation); - } + }); - /** - * Get collection of resources from relation field. - * - * @param relationName field - * @return collection relation - */ - public Set getRelationCheckedFiltered(String relationName) { - return filter(ReadPermission.class, (Set) getRelation(relationName, true)); + allResources = allResources.doOnComplete(() -> { + Set missedIds = Sets.difference(new HashSet<>(ids), foundIds); + if (!missedIds.isEmpty()) { + throw new InvalidObjectIdentifierException(missedIds.toString(), relationship.getName()); + } + }); + + return allResources; } /** - * Get collection of resources from relation field. + * Get observable of resources from relation field. * - * @param relationName field + * @param relationship relationship * @return collection relation */ - public Set getRelationCheckedFilteredWithSortingAndPagination(String relationName) { - return filter(ReadPermission.class, (Set) getRelationWithSortingAndPagination(relationName, true)); + public Observable getRelationCheckedFiltered( + com.yahoo.elide.core.request.Relationship relationship) { + return filter(ReadPermission.class, + Optional.ofNullable(relationship.getProjection().getFilterExpression()), + relationship.getProjection().getRequestedFields(), + getRelation(relationship, true)); } - private Set getRelationUncheckedUnfiltered(String relationName) { - return getRelation(relationName, false); + private Observable getRelationUncheckedUnfiltered(String relationName) { + assertPropertyExists(relationName); + return getRelation(com.yahoo.elide.core.request.Relationship.builder() + .name(relationName) + .alias(relationName) + .projection(EntityProjection.builder() + .type(dictionary.getParameterizedType(getResourceType(), relationName)) + .build()) + .build(), false); } - private Set getRelation(String relationName, boolean checked) { - - if (checked && !checkRelation(relationName)) { - return Collections.emptySet(); + private void assertPropertyExists(String propertyName) { + if (propertyName == null || dictionary.getParameterizedType(obj, propertyName) == null) { + throw new InvalidAttributeException(propertyName, this.getTypeName()); } - Optional expression = getExpressionForRelation(relationName); - return getRelationUnchecked(relationName, expression); } - private Optional getExpressionForRelation(String relationName) { - final Class entityClass = dictionary.getParameterizedType(obj, relationName); - if (entityClass == null) { - throw new InvalidAttributeException(relationName, type); - } - final String valType = dictionary.getJsonAliasFor(entityClass); - return requestScope.getFilterExpressionByType(valType); - } + private Observable getRelation(com.yahoo.elide.core.request.Relationship relationship, + boolean checked) { - /** - * Gets the relational entities to a entity (author/1/books) - books would be fetched here. - * @param relationName The relationship name - eg. books - * @param checked The flag to denote if we are doing security checks on this relationship - * @return The resulting records from underlying data store - */ - private Set getRelationWithSortingAndPagination(String relationName, boolean checked) { - if (checked && !checkRelation(relationName)) { - return Collections.emptySet(); + if (checked && !checkRelation(relationship)) { + return Observable.empty(); } - final Class relationClass = dictionary.getParameterizedType(obj, relationName); - if (! requestScope.getPagination().isDefaultInstance() - && !CanPaginateVisitor.canPaginate(relationClass, dictionary, requestScope)) { - throw new InvalidPredicateException(String.format("Cannot paginate %s", + Type relationClass = dictionary.getParameterizedType(obj, relationship.getName()); + + Optional pagination = Optional.ofNullable(relationship.getProjection().getPagination()); + + if (pagination.filter(Predicates.not(Pagination::isDefaultInstance)).isPresent() + && !CanPaginateVisitor.canPaginate( + relationClass, + dictionary, + requestScope, + relationship.getProjection().getRequestedFields())) { + + throw new BadRequestException(String.format("Cannot paginate %s", dictionary.getJsonAliasFor(relationClass))); } - Optional filterExpression = getExpressionForRelation(relationName); - return getRelationUncheckedWithSortingAndPagination(relationName, filterExpression); + 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.core.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); - } + assertPropertyExists(relationName); checkFieldAwareDeferPermissions(ReadPermission.class, relationName, null, null); - final PermissionExecutor executor = requestScope.getPermissionExecutor(); - try { - //The collection can be skipped if the User check for the objects inside the relationship evaluates to false - executor.checkUserPermissions(dictionary.getParameterizedType(obj, relationName), ReadPermission.class); - } catch (ForbiddenAccessException e) { - return false; - } - - return true; + return !shouldSkipCollection( + dictionary.getParameterizedType(obj, relationName), + ReadPermission.class, + requestScope, + relationship.getProjection().getRequestedFields()); } /** - * Get collection of resources from relation field. + * Get collection of resources from relation field. Does not filter the relationship and does + * not invoke lifecycle hooks. * - * @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) { - if (!checkRelation(relationName)) { - return Collections.emptySet(); + public Observable getRelationChecked(com.yahoo.elide.core.request.Relationship relationship) { + if (!checkRelation(relationship)) { + return Observable.empty(); } - return getRelationUnchecked(relationName, filterExpression); - + return getRelationUnchecked(relationship); } /** - * Retrieve an uncheck set of relations. - * - * @param relationName field - * @param filterExpression An optional filter expression - * @return the resources in the relationship + * Retrieve an unchecked set of relations. */ - private Set getRelationUnchecked(String relationName, - Optional filterExpression) { - RelationshipType type = getRelationshipType(relationName); - final Class relationClass = dictionary.getParameterizedType(obj, relationName); - - //Invoke filterExpressionCheck and then merge with filterExpression. - Optional permissionFilter = getPermissionFilterExpression(relationClass, requestScope); - if (permissionFilter.isPresent()) { - if (filterExpression.isPresent()) { - FilterExpression mergedExpression = - new AndFilterExpression(filterExpression.get(), permissionFilter.get()); - filterExpression = Optional.of(mergedExpression); - } else { - filterExpression = permissionFilter; - } - } - - Object val; + private Observable getRelationUnchecked( + com.yahoo.elide.core.request.Relationship relationship) { + String relationName = relationship.getName(); + FilterExpression filterExpression = relationship.getProjection().getFilterExpression(); + Pagination pagination = relationship.getProjection().getPagination(); + Sorting sorting = relationship.getProjection().getSorting(); - /* If elide was configured for Elide 3.0 data store interface */ - if (requestScope.useFilterExpressions()) { - String path = requestScope.getPath(); - val = requestScope.getTransaction() - .getRelation(obj, type, relationName, relationClass, dictionary, - filterExpression, null, null); - - /* Otherwise use the Elide 2.0 interface */ - } else { - - /* Convert the expression to a set of predicates */ - Set filters; - PredicateExtractionVisitor visitor = new PredicateExtractionVisitor(); - if (filterExpression.isPresent()) { - filters = filterExpression.get().accept(visitor); - } else { - filters = Collections.emptySet(); - } - val = requestScope.getTransaction() - .getRelation(obj, type, relationName, relationClass, dictionary, filters); + RelationshipType type = getRelationshipType(relationName); + final Type relationClass = dictionary.getParameterizedType(obj, relationName); + if (relationClass == null) { + throw new InvalidAttributeException(relationName, this.getTypeName()); } - if (val == null) { - return Collections.emptySet(); - } + //Invoke filterExpressionCheck and then merge with filterExpression. + Optional permissionFilter = getPermissionFilterExpression(relationClass, + requestScope, relationship.getProjection().getRequestedFields()); + Optional computedFilters = Optional.ofNullable(filterExpression); - Set> resources = Sets.newLinkedHashSet(); - if (val instanceof Collection) { - Collection filteredVal = (Collection) val; - resources = new PersistentResourceSet(this, filteredVal, requestScope); - } else if (type.isToOne()) { - resources = new SingleElementSet(new PersistentResource(this, val, requestScope)); - } else { - resources.add(new PersistentResource(this, val, requestScope)); + if (permissionFilter.isPresent() && filterExpression != null) { + FilterExpression mergedExpression = + new AndFilterExpression(filterExpression, permissionFilter.get()); + computedFilters = Optional.of(mergedExpression); + } else if (permissionFilter.isPresent()) { + computedFilters = permissionFilter; } - return (Set) resources; - } - - /** - * Fetches the relationship entities with sorting and pagination support via HQLTransaction. - * @param relationName The entity whose relationships we want to fetch - * @param filterExpression An optional filter expression - * @return The persistent resources - */ - private Set getRelationUncheckedWithSortingAndPagination( - String relationName, - Optional filterExpression) { - RelationshipType type = getRelationshipType(relationName); - final Class relationClass = dictionary.getParameterizedType(obj, relationName); + com.yahoo.elide.core.request.Relationship modifiedRelationship = relationship.copyOf() + .projection(relationship.getProjection().copyOf() + .filterExpression(computedFilters.orElse(null)) + .sorting(sorting) + .pagination(pagination) + .build() + ).build(); - Object val; + Observable resources; - String path = requestScope.getPath(); - /* If elide was configured for Elide 3.0 data store interface */ - if (requestScope.useFilterExpressions()) { - val = requestScope.getTransaction() - .getRelation(obj, type, relationName, relationClass, dictionary, - filterExpression, requestScope.getSorting(), requestScope.getPagination()); + if (type.isToMany()) { + DataStoreIterable val = transaction.getToManyRelation(transaction, obj, modifiedRelationship, requestScope); - /* Otherwise use the Elide 2.0 interface */ - } else { - /* Convert the expression to a set of predicates */ - Set filters; - PredicateExtractionVisitor visitor = new PredicateExtractionVisitor(); - if (filterExpression.isPresent()) { - filters = filterExpression.get().accept(visitor); - } else { - filters = Collections.emptySet(); + if (val == null) { + return Observable.empty(); } - - val = requestScope.getTransaction() - .getRelationWithSortingAndPagination(obj, type, relationName, relationClass, dictionary, - filters, requestScope.getSorting(), requestScope.getPagination()); - } - - if (val == null) { - return Collections.emptySet(); - } - - Set> resources = Sets.newLinkedHashSet(); - if (val instanceof Collection) { - Collection filteredVal = (Collection) val; - resources = new PersistentResourceSet(this, filteredVal, requestScope); - } else if (type.isToOne()) { - resources = new SingleElementSet(new PersistentResource(this, val, requestScope)); + resources = Observable.fromIterable( + new PersistentResourceSet(this, relationName, val, requestScope)); } else { - resources.add(new PersistentResource(this, val, requestScope)); + Object val = transaction.getToOneRelation(transaction, obj, modifiedRelationship, requestScope); + if (val == null) { + return Observable.empty(); + } + resources = Observable.fromArray(new PersistentResource(val, this, relationName, + requestScope.getUUIDFor(val), requestScope)); } - return (Set) resources; - } - - /** - * Determine whether or not to skip loading a collection. - * - * @param resourceClass Resource class - * @param annotationClass Annotation class - * @param requestScope Request scope - * @param type parameter - * @return True if collection should be skipped (i.e. denied access), false otherwise - */ - private static boolean shouldSkipCollection(Class resourceClass, - Class annotationClass, - RequestScope requestScope) { - try { - requestScope.getPermissionExecutor().checkUserPermissions(resourceClass, annotationClass); - } catch (ForbiddenAccessException e) { - return true; - } - return false; + return resources; } /** @@ -1097,10 +1365,29 @@ public RelationshipType getRelationshipType(String relation) { /** * Get the value for a particular attribute (i.e. non-relational field) + * * @param attr Attribute name * @return Object value for attribute */ + @Deprecated public Object getAttribute(String attr) { + assertPropertyExists(attr); + + return this.getAttribute( + Attribute.builder() + .name(attr) + .alias(attr) + .type(dictionary.getParameterizedType(getResourceType(), attr)) + .build()); + } + + /** + * 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); } @@ -1109,12 +1396,14 @@ public Object getAttribute(String attr) { * * @return bean object */ + @Override public T getObject() { return obj; } /** * Sets object. + * * @param obj the obj */ public void setObject(T obj) { @@ -1126,17 +1415,20 @@ public void setObject(T obj) { * * @return type resource class */ + @Override @JsonIgnore - public Class getResourceClass() { - return (Class) dictionary.lookupEntityClass(obj.getClass()); + public Type getResourceType() { + return (Type) dictionary.lookupBoundClass(getType(obj)); } /** * Gets type. + * * @return the type */ - public String getType() { - return type; + @Override + public String getTypeName() { + return typeName; } @Override @@ -1154,7 +1446,7 @@ public int hashCode() { // that newly created object within the context of the request. Thus, if any such action was // required, the user would be forced to provide a UUID anyway. String id = dictionary.getId(getObject()); - if (uuid.isPresent() && "0".equals(id)) { + if (uuid.isPresent() && ("0".equals(id) || "null".equals(id))) { hashCode = Objects.hashCode(uuid); } else { hashCode = Objects.hashCode(id); @@ -1171,13 +1463,23 @@ public boolean equals(Object obj) { return true; } String theirId = dictionary.getId(that.getObject()); - return this.matchesId(theirId) && Objects.equals(this.type, that.type); + return this.matchesId(theirId) && Objects.equals(this.typeName, that.typeName); } 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 */ public ResourceLineage getLineage() { @@ -1186,6 +1488,7 @@ public ResourceLineage getLineage() { /** * Gets dictionary. + * * @return the dictionary */ public EntityDictionary getDictionary() { @@ -1194,8 +1497,10 @@ public EntityDictionary getDictionary() { /** * Gets request scope. + * * @return the request scope */ + @Override public RequestScope getRequestScope() { return requestScope; } @@ -1211,25 +1516,43 @@ 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(() -> getRelationships(projection), this::getAttributes); } /** * Fetch a resource with support for lambda function for getting relationships and attributes. + * * @param relationshipSupplier The relationship supplier (getRelationships()) + * @param attributeSupplier The attribute supplier + * @return The Resource + */ + private Resource toResource(final Supplier> relationshipSupplier, + final Supplier> attributeSupplier) { + return toResource(relationshipSupplier.get(), attributeSupplier.get()); + } + + /** + * Convert a persistent resource to a resource. + * + * @param relationships The relationships + * @param attributes The attributes * @return The Resource */ - public Resource toResource(final Supplier> relationshipSupplier, - final Supplier> attributeSupplier) { - final Resource resource = new Resource(type, (obj == null) + public Resource toResource(final Map relationships, + final Map attributes) { + final Resource resource = new Resource(typeName, (obj == null) ? uuid.orElseThrow( () -> new InvalidEntityBodyException("No id found on object")) : dictionary.getId(obj)); - resource.setRelationships(relationshipSupplier.get()); - resource.setAttributes(attributeSupplier.get()); + resource.setRelationships(relationships); + resource.setAttributes(attributes); + if (requestScope.getElideSettings().isEnableJsonLinks()) { + resource.setLinks(requestScope.getElideSettings().getJsonApiLinks().getResourceLevelLinks(this)); + } return resource; } @@ -1239,7 +1562,19 @@ public Resource toResource(final Supplier> relationshi * @return Relationship mapping */ protected Map getRelationships() { - return getRelationshipsWithRelationshipFunction(this::getRelationCheckedFiltered); + return getRelationshipsWithRelationshipFunction((relationName) -> { + Optional filterExpression = requestScope.getExpressionForRelation(getResourceType(), + relationName); + + return getRelationCheckedFiltered(com.yahoo.elide.core.request.Relationship.builder() + .alias(relationName) + .name(relationName) + .projection(EntityProjection.builder() + .type(dictionary.getParameterizedType(getResourceType(), relationName)) + .filterExpression(filterExpression.orElse(null)) + .build()) + .build()); + }); } /** @@ -1247,38 +1582,48 @@ protected Map getRelationships() { * * @return Relationship mapping */ - protected Map getRelationshipsWithSortingAndPagination() { - return getRelationshipsWithRelationshipFunction(this::getRelationCheckedFilteredWithSortingAndPagination); + private Map getRelationships(EntityProjection projection) { + return getRelationshipsWithRelationshipFunction( + (relationName) -> getRelationCheckedFiltered(projection.getRelationship(relationName) + .orElseThrow(IllegalStateException::new) + )); } /** * Get relationship mappings. * + * @param relationshipFunction a function to load the value of a relationship. Takes a string of the relationship + * name and returns the relationship's value. * @return Relationship mapping */ protected Map getRelationshipsWithRelationshipFunction( - final Function> relationshipFunction) { + final Function> relationshipFunction) { final Map relationshipMap = new LinkedHashMap<>(); final Set relationshipFields = filterFields(dictionary.getRelationships(obj)); for (String field : relationshipFields) { - TreeMap orderedById = new TreeMap<>(comparator); - for (PersistentResource relationship : relationshipFunction.apply(field)) { + TreeMap orderedById = new TreeMap<>(lengthFirstComparator); + for (PersistentResource relationship : relationshipFunction.apply(field).toList().blockingGet()) { orderedById.put(relationship.getId(), - new ResourceIdentifier(relationship.getType(), relationship.getId()).castToResource()); + new ResourceIdentifier(relationship.getTypeName(), relationship.getId()).castToResource()); } - Collection resources = orderedById.values(); + Observable resources = Observable.fromIterable(orderedById.values()); Data data; RelationshipType relationshipType = getRelationshipType(field); if (relationshipType.isToOne()) { - data = resources.isEmpty() ? new Data<>((Resource) null) : new Data<>(resources.iterator().next()); + data = new Data<>(firstOrNullIfEmpty(resources)); } else { data = new Data<>(resources); } - // TODO - links - relationshipMap.put(field, new Relationship(null, data)); + Map links = null; + if (requestScope.getElideSettings().isEnableJsonLinks()) { + links = requestScope.getElideSettings() + .getJsonApiLinks() + .getRelationshipLinks(this, field); + } + relationshipMap.put(field, new Relationship(links, data)); } return relationshipMap; @@ -1302,19 +1647,26 @@ protected Map getAttributes() { /** * Sets value. + * * @param fieldName the field name - * @param newValue the new value + * @param newValue the new value */ protected void setValueChecked(String fieldName, Object newValue) { - checkFieldAwareDeferPermissions(UpdatePermission.class, fieldName, newValue, getValueUnchecked(fieldName)); + Object existingValue = getValueUnchecked(fieldName); + + // TODO: Need to refactor this logic. For creates this is properly converted in the executor. This logic + // should be explicitly encapsulated here, not there. + checkFieldAwareDeferPermissions(UpdatePermission.class, fieldName, newValue, existingValue); + setValue(fieldName, newValue); } /** * Nulls the relationship or attribute and checks update permissions. * Invokes the set[fieldName] method on the target object OR set the field with the corresponding name. + * * @param fieldName the field name to set or invoke equivalent set method - * @param oldValue the old value + * @param oldValue the old value */ protected void nullValue(String fieldName, PersistentResource oldValue) { if (oldValue == null) { @@ -1322,19 +1674,20 @@ protected void nullValue(String fieldName, PersistentResource oldValue) { } String inverseField = getInverseRelationField(fieldName); if (!inverseField.isEmpty()) { - oldValue.checkFieldAwarePermissions(UpdatePermission.class, inverseField, null, getObject()); + oldValue.checkFieldAwareDeferPermissions(UpdatePermission.class, inverseField, null, getObject()); } this.setValueChecked(fieldName, null); } /** * Gets a value from an entity and checks read permissions. - * @param fieldName the field name + * + * @param attribute the attribute to fetch. * @return value value */ - protected Object getValueChecked(String fieldName) { - checkFieldAwareDeferPermissions(ReadPermission.class, fieldName, (Object) null, (Object) null); - return getValue(getObject(), fieldName, dictionary); + protected Object getValueChecked(Attribute attribute) { + checkFieldAwareDeferPermissions(ReadPermission.class, attribute.getName(), null, null); + return transaction.getAttribute(getObject(), attribute, requestScope); } /** @@ -1344,364 +1697,165 @@ protected Object getValueChecked(String fieldName) { * @return Value */ protected Object getValueUnchecked(String fieldName) { - return getValue(getObject(), fieldName, dictionary); - } - - /** - * Adds a new element to a collection and tests update permission. - * @param collection the collection - * @param collectionName the collection name - * @param toAdd the to add - * @return True if added to collection false otherwise (i.e. element already in collection) - */ - protected boolean addToCollection(Collection collection, String collectionName, PersistentResource toAdd) { - final Collection singleton = Collections.singleton(toAdd.getObject()); - final Collection original = copyCollection(collection); - checkFieldAwareDeferPermissions( - UpdatePermission.class, - collectionName, - CollectionUtils.union(CollectionUtils.emptyIfNull(collection), singleton), - original); - if (collection == null) { - collection = Collections.singleton(toAdd.getObject()); - Object value = getValueUnchecked(collectionName); - if ((value == null && toAdd.getObject() != null) || (value != null && !value.equals(toAdd.getObject()))) { - this.setValueChecked(collectionName, collection); - return true; - } - } else { - if (!collection.contains(toAdd.getObject())) { - collection.add(toAdd.getObject()); - auditField(new ChangeSpec(this, collectionName, original, collection)); - return true; - } - } - return false; + return getValue(getObject(), fieldName, requestScope); } - /** - * Deletes an existing element in a collection and tests update and delete permissions. - * @param collection the collection - * @param collectionName the collection name - * @param toDelete the to delete - * @param isInverseCheck Whether or not the deletion is already coming from cleaning up an inverse. - * Without this parameter, we could find ourselves in a loop of checks. - * TODO: This is a band-aid for a quick fix. This should certainly be refactored. - */ - protected void delFromCollection( - Collection collection, + protected boolean modifyCollection( + Collection toModify, String collectionName, - PersistentResource toDelete, - boolean isInverseCheck) { - final Collection original = copyCollection(collection); + Collection toAdd, + Collection toRemove, + boolean updateInverse) { + + Collection copyOfOriginal = copyCollection(toModify); + + Collection modified = CollectionUtils.union(CollectionUtils.emptyIfNull(toModify), toAdd); + modified = CollectionUtils.subtract(modified, toRemove); + checkFieldAwareDeferPermissions( UpdatePermission.class, collectionName, - CollectionUtils.disjunction(collection, Collections.singleton(toDelete.getObject())), - original - ); + modified, + copyOfOriginal); - String inverseField = getInverseRelationField(collectionName); - if (!isInverseCheck && !inverseField.isEmpty()) { - // Compute the ChangeSpec for the inverse relation and check whether or not we have access - // to apply this change to that field. - final Object originalValue = toDelete.getValueUnchecked(inverseField); - final Collection originalBidirectional; - - if (originalValue instanceof Collection) { - originalBidirectional = copyCollection((Collection) originalValue); - } else { - originalBidirectional = Collections.singleton(originalValue); + if (updateInverse) { + for (Object adding : toAdd) { + addInverseRelation(collectionName, adding); } - final Collection removedBidrectional = CollectionUtils - .disjunction(Collections.singleton(this.getObject()), originalBidirectional); - - toDelete.checkFieldAwareDeferPermissions( - UpdatePermission.class, - inverseField, - removedBidrectional, - originalBidirectional - ); + for (Object removing : toRemove) { + deleteInverseRelation(collectionName, removing); + } } - if (collection == null) { - return; - } + if (toModify == null) { + this.setValueChecked(collectionName, modified); + return true; + } else { + if (copyOfOriginal.equals(modified)) { + return false; + } + toModify.addAll(toAdd); + toModify.removeAll(toRemove); - collection.remove(toDelete.getObject()); - auditField(new ChangeSpec(this, collectionName, original, collection)); + triggerUpdate(collectionName, copyOfOriginal, modified); + return true; + } } /** * Invoke the set[fieldName] method on the target object OR set the field with the corresponding name. + * * @param fieldName the field name to set or invoke equivalent set method - * @param value the value to set + * @param value the value to set */ protected void setValue(String fieldName, Object value) { - Class targetClass = obj.getClass(); final Object original = getValueUnchecked(fieldName); - try { - Class fieldClass = dictionary.getType(targetClass, fieldName); - String realName = dictionary.getNameFromAlias(obj, fieldName); - fieldName = (realName != null) ? realName : fieldName; - String setMethod = "set" + WordUtils.capitalize(fieldName); - Method method = EntityDictionary.findMethod(targetClass, setMethod, fieldClass); - method.invoke(obj, coerce(value, fieldName, fieldClass)); - } catch (IllegalAccessException | InvocationTargetException e) { - throw new InvalidAttributeException(fieldName, type); - } catch (IllegalArgumentException | NoSuchMethodException noMethod) { - try { - Field field = targetClass.getField(fieldName); - field.set(obj, coerce(value, fieldName, field.getType())); - } catch (NoSuchFieldException | IllegalAccessException noField) { - throw new InvalidAttributeException(fieldName, type); - } - } - - runTriggers(OnUpdate.class, fieldName); - this.requestScope.queueCommitTrigger(this, fieldName); - auditField(new ChangeSpec(this, fieldName, original, value)); - } - - void runTriggers(Class annotationClass) { - runTriggers(annotationClass, ""); - } - - void runTriggers(Class annotationClass, String fieldName) { - Class targetClass = obj.getClass(); - - Collection methods = dictionary.getTriggers(targetClass, annotationClass, fieldName); - for (Method method : methods) { - try { - method.invoke(obj); - } catch (ReflectiveOperationException e) { - throw new IllegalArgumentException(e); - } - } - } - - /** - * Coerce provided value into expected class type. - * - * @param value provided value - * @param fieldClass expected class type - * @return coerced value - */ - private Object coerce(Object value, String fieldName, Class fieldClass) { - if (fieldClass != null && Collection.class.isAssignableFrom(fieldClass) && value instanceof Collection) { - return coerceCollection((Collection) value, fieldName, fieldClass); - } - - if (fieldClass != null && Map.class.isAssignableFrom(fieldClass) && value instanceof Map) { - return coerceMap((Map) value, fieldName, fieldClass); - } - - return CoerceUtil.coerce(value, fieldClass); - } - - private Collection coerceCollection(Collection values, String fieldName, Class fieldClass) { - Class providedType = dictionary.getParameterizedType(obj, fieldName); - - // check if collection is of and contains the correct types - if (fieldClass.isAssignableFrom(values.getClass())) { - boolean valid = true; - for (Object member : values) { - if (member != null && !providedType.isAssignableFrom(member.getClass())) { - valid = false; - break; - } - } - if (valid) { - return values; - } - } - - ArrayList list = new ArrayList<>(values.size()); - for (Object member : values) { - list.add(CoerceUtil.coerce(member, providedType)); - } - - if (Set.class.isAssignableFrom(fieldClass)) { - return new LinkedHashSet<>(list); - } - - return list; - } - - private Map coerceMap(Map values, String fieldName, Class fieldClass) { - Class keyType = dictionary.getParameterizedType(obj, fieldName, 0); - Class valueType = dictionary.getParameterizedType(obj, fieldName, 1); - - // Verify the existing Map - if (isValidParameterizedMap(values, keyType, valueType)) { - return values; - } - LinkedHashMap result = new LinkedHashMap<>(values.size()); - for (Map.Entry entry : values.entrySet()) { - result.put(CoerceUtil.coerce(entry.getKey(), keyType), CoerceUtil.coerce(entry.getValue(), valueType)); - } - - return result; - } - - private boolean isValidParameterizedMap(Map values, Class keyType, Class valueType) { - for (Map.Entry entry : values.entrySet()) { - Object key = entry.getKey(); - Object value = entry.getValue(); - if ((key != null && !keyType.isAssignableFrom(key.getClass())) - || (value != null && !valueType.isAssignableFrom(value.getClass()))) { - return false; - } - } - return true; - } + dictionary.setValue(obj, fieldName, value); - /** - * Invoke the get[fieldName] method on the target object OR get the field with the corresponding name. - * @param target the object to get - * @param fieldName the field name to get or invoke equivalent get method - * @param dictionary the dictionary - * @return the value - */ - public static Object getValue(Object target, String fieldName, EntityDictionary dictionary) { - AccessibleObject accessor = dictionary.getAccessibleObject(target, fieldName); - try { - if (accessor instanceof Method) { - return ((Method) accessor).invoke(target); - } else if (accessor instanceof Field) { - return ((Field) accessor).get(target); - } - } catch (IllegalAccessException | InvocationTargetException e) { - throw new InvalidAttributeException(fieldName, dictionary.getJsonAliasFor(target.getClass())); - } - throw new InvalidAttributeException(fieldName, dictionary.getJsonAliasFor(target.getClass())); + triggerUpdate(fieldName, original, value); } /** * If a bidirectional relationship exists, attempts to delete itself from the inverse * relationship. Given A to B as the relationship, A corresponds to this and B is the inverse. - * @param relationName The name of the relationship on this (A) object. + * + * @param relationName The name of the relationship on this (A) object. * @param inverseEntity The value (B) which has been deleted from this object. */ protected void deleteInverseRelation(String relationName, Object inverseEntity) { - String inverseRelationName = getInverseRelationField(relationName); + String inverseField = getInverseRelationField(relationName); - if (!inverseRelationName.equals("")) { - Class inverseRelationType = dictionary.getType(inverseEntity.getClass(), inverseRelationName); + if (!"".equals(inverseField)) { + Type inverseType = dictionary.getType(inverseEntity, inverseField); - PersistentResource inverseResource = new PersistentResource(this, inverseEntity, getRequestScope()); - Object inverseRelation = inverseResource.getValueUnchecked(inverseRelationName); + String uuid = requestScope.getUUIDFor(inverseEntity); + PersistentResource inverseResource = new PersistentResource(inverseEntity, + this, relationName, uuid, requestScope); + Object inverseRelation = inverseResource.getValueUnchecked(inverseField); if (inverseRelation == null) { return; } if (inverseRelation instanceof Collection) { - inverseResource.delFromCollection((Collection) inverseRelation, inverseRelationName, this, true); - } else if (inverseRelationType.equals(this.getResourceClass())) { - inverseResource.nullValue(inverseRelationName, this); + inverseResource.modifyCollection((Collection) inverseRelation, inverseField, + Collections.emptySet(), Set.of(this.getObject()), false); + } else if (inverseType.isAssignableFrom(this.getResourceType())) { + inverseResource.nullValue(inverseField, this); } else { throw new InternalServerErrorException("Relationship type mismatch"); } inverseResource.markDirty(); + + RelationshipType inverseRelationType = inverseResource.getRelationshipType(inverseField); + if (inverseRelationType.isToOne()) { + //hook for updateToOneRelation + transaction.updateToOneRelation(transaction, inverseEntity, inverseField, null, requestScope); + } else { + //hook for updateToManyRelation + assert (inverseRelation instanceof Collection) : inverseField + " not a collection"; + transaction.updateToManyRelation(transaction, inverseEntity, inverseField, + new LinkedHashSet<>(), Sets.newHashSet(obj), requestScope); + } } } private boolean hasInverseRelation(String relationName) { String inverseField = getInverseRelationField(relationName); - return inverseField != null && !inverseField.isEmpty(); + return StringUtils.isNotEmpty(inverseField); } private String getInverseRelationField(String relationName) { - return dictionary.getRelationInverse(obj.getClass(), relationName); + return dictionary.getRelationInverse(type, relationName); } /** * If a bidirectional relationship exists, attempts to add itself to the inverse * relationship. Given A to B as the relationship, A corresponds to this and B is the inverse. + * * @param relationName The name of the relationship on this (A) object. - * @param relationValue The value (B) which has been added to this object. + * @param inverseObj The value (B) which has been added to this object. */ - protected void addInverseRelation(String relationName, Object relationValue) { - Object inverseEntity = relationValue; // Assigned to improve readability. - String inverseRelationName = dictionary.getRelationInverse(obj.getClass(), relationName); + protected void addInverseRelation(String relationName, Object inverseObj) { + String inverseName = dictionary.getRelationInverse(type, relationName); - if (!inverseRelationName.equals("")) { - Class inverseRelationType = dictionary.getType(inverseEntity.getClass(), inverseRelationName); + if (!"".equals(inverseName)) { + Type inverseType = dictionary.getType(inverseObj, inverseName); - PersistentResource inverseResource = new PersistentResource(this, inverseEntity, getRequestScope()); - Object inverseRelation = inverseResource.getValueUnchecked(inverseRelationName); + String uuid = requestScope.getUUIDFor(inverseObj); + PersistentResource inverseResource = new PersistentResource(inverseObj, + this, relationName, uuid, requestScope); + Object inverseRelation = inverseResource.getValueUnchecked(inverseName); - if (Collection.class.isAssignableFrom(inverseRelationType)) { + if (COLLECTION_TYPE.isAssignableFrom(inverseType)) { if (inverseRelation != null) { - inverseResource.addToCollection((Collection) inverseRelation, inverseRelationName, this); + inverseResource.modifyCollection((Collection) inverseRelation, inverseName, + Set.of(this.getObject()), Collections.emptySet(), false); } else { - inverseResource.setValueChecked(inverseRelationName, Collections.singleton(this.getObject())); + inverseResource.setValueChecked(inverseName, Collections.singleton(this.getObject())); } - } else if (inverseRelationType.equals(this.getResourceClass())) { - inverseResource.setValueChecked(inverseRelationName, this.getObject()); + } else if (inverseType.isAssignableFrom(this.getResourceType())) { + inverseResource.setValueChecked(inverseName, this.getObject()); } else { throw new InternalServerErrorException("Relationship type mismatch"); } inverseResource.markDirty(); - } - } - - /** - * Filter a set of PersistentResources. - * - * @param the type parameter - * @param the type parameter - * @param permission the permission - * @param resources the resources - * @return Filtered set of resources - */ - protected static Set> filter(Class permission, - Set> resources) { - return filter(permission, resources, false); - } - /** - * Filter a set of PersistentResources. - * - * @param the type parameter - * @param the type parameter - * @param permission the permission - * @param resources the resources - * @param skipNew - * @return Filtered set of resources - */ - protected static Set> filter(Class permission, - Set> resources, - boolean skipNew) { - Set> filteredSet = new LinkedHashSet<>(); - for (PersistentResource resource : resources) { - PermissionExecutor permissionExecutor = resource.getRequestScope().getPermissionExecutor(); - try { - if (!(skipNew && resource.getRequestScope().getNewResources().contains(resource))) { - if (!permissionExecutor - .shouldShortCircuitPermissionChecks(permission, resource.getResourceClass(), null)) { - ExpressionResult expressionResult - = permissionExecutor.checkUserPermissions(resource.getResourceClass(), permission); - if (expressionResult == ExpressionResult.PASS) { - filteredSet.add(resource); - continue; - } - resource.checkFieldAwarePermissions(permission); - } - } - filteredSet.add(resource); - } catch (ForbiddenAccessException e) { - // Do nothing. Filter from set. + RelationshipType inverseRelationType = inverseResource.getRelationshipType(inverseName); + if (inverseRelationType.isToOne()) { + //hook for updateToOneRelation + transaction.updateToOneRelation(transaction, inverseObj, inverseName, + obj, requestScope); + } else { + //hook for updateToManyRelation + assert (inverseRelation == null || inverseRelation instanceof Collection) + : inverseName + " not a collection"; + transaction.updateToManyRelation(transaction, inverseObj, inverseName, + Sets.newHashSet(obj), new LinkedHashSet<>(), requestScope); } } - // keep original SingleElementSet - if (resources instanceof SingleElementSet && resources.equals(filteredSet)) { - return resources; - } - return filteredSet; } /** @@ -1714,22 +1868,9 @@ protected Set filterFields(Collection fields) { Set filteredSet = new LinkedHashSet<>(); for (String field : fields) { try { - if (checkIncludeSparseField(requestScope.getSparseFields(), type, field)) { - if (requestScope.getPermissionExecutor() - .shouldShortCircuitPermissionChecks(ReadPermission.class, getResourceClass(), field)) { - filteredSet.add(field); - continue; - } - - ExpressionResult expressionResult = requestScope.getPermissionExecutor() - .checkUserPermissions(this, ReadPermission.class, field); - - if (expressionResult == ExpressionResult.PASS) { - filteredSet.add(field); - continue; - } - checkFieldAwarePermissions(ReadPermission.class, field, (Object) null, (Object) null); + if (checkIncludeSparseField(requestScope.getSparseFields(), typeName, field)) { + checkFieldAwareReadPermissions(field); filteredSet.add(field); } } catch (ForbiddenAccessException e) { @@ -1739,62 +1880,43 @@ protected Set filterFields(Collection fields) { return filteredSet; } - private static ExpressionResult checkPermission( - Class annotationClass, PersistentResource resource) { - return resource.requestScope.getPermissionExecutor().checkPermission(annotationClass, resource); - } + /** + * Queue the @*Update triggers iff this is not a newly created object (otherwise we run @*Create) + */ + private void triggerUpdate(String fieldName, Object original, Object value) { + ChangeSpec changeSpec = new ChangeSpec(this, fieldName, original, value); + LifeCycleHookBinding.Operation action = isNewlyCreated() + ? CREATE + : UPDATE; - private ExpressionResult checkFieldAwarePermissions(Class annotationClass) { - return requestScope.getPermissionExecutor().checkPermission(annotationClass, this); + requestScope.publishLifecycleEvent(this, fieldName, action, Optional.of(changeSpec)); + requestScope.publishLifecycleEvent(this, action); + auditField(new ChangeSpec(this, fieldName, original, value)); } - private ExpressionResult checkFieldAwarePermissions(Class annotationClass, - String fieldName, - Object modified, - Object original) { - ChangeSpec changeSpec = (UpdatePermission.class.isAssignableFrom(annotationClass)) - ? new ChangeSpec(this, fieldName, original, modified) - : null; + private ExpressionResult checkFieldAwarePermissions( + Class annotationClass, + Set requestedFields + ) { + return requestScope.getPermissionExecutor().checkPermission(annotationClass, this, requestedFields); + } + private ExpressionResult checkFieldAwareReadPermissions(String fieldName) { return requestScope.getPermissionExecutor() - .checkSpecificFieldPermissions(this, changeSpec, annotationClass, fieldName); + .checkSpecificFieldPermissions(this, null, ReadPermission.class, fieldName); } private ExpressionResult checkFieldAwareDeferPermissions(Class annotationClass, - String fieldName, - Object modified, - Object original) { + String fieldName, + Object modified, + Object original) { ChangeSpec changeSpec = (UpdatePermission.class.isAssignableFrom(annotationClass)) ? new ChangeSpec(this, fieldName, original, modified) : null; - // Defer checks for newly created objects if: - // 1. This is a patch extension request - // 2. This is an update request (note: changeSpec != null is a faster change check than rechecking permission) - if (requestScope.getNewResources().contains(this) - && ((requestScope instanceof PatchRequestScope) - || changeSpec != null)) { - return requestScope - .getPermissionExecutor() - .checkSpecificFieldPermissionsDeferred(this, changeSpec, annotationClass, fieldName); - } + return requestScope .getPermissionExecutor() - .checkSpecificFieldPermissions(this, changeSpec, annotationClass, fieldName); - } - - protected static boolean checkIncludeSparseField(Map> sparseFields, String type, - String fieldName) { - if (!sparseFields.isEmpty()) { - if (!sparseFields.containsKey(type)) { - return false; - } - - if (!sparseFields.get(type).contains(fieldName)) { - return false; - } - } - - return true; + .checkSpecificFieldPermissionsDeferred(this, changeSpec, annotationClass, fieldName); } /** @@ -1804,7 +1926,7 @@ protected static boolean checkIncludeSparseField(Map> sparse */ protected void auditField(final ChangeSpec changeSpec) { final String fieldName = changeSpec.getFieldName(); - Audit[] annotations = dictionary.getAttributeOrRelationAnnotations(getResourceClass(), + Audit[] annotations = dictionary.getAttributeOrRelationAnnotations(getResourceType(), Audit.class, fieldName ); @@ -1816,7 +1938,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."); @@ -1827,18 +1949,19 @@ protected void auditField(final ChangeSpec changeSpec) { /** * Audit an action on an entity. * - * @param action the action + * @param action the action + * @param changeSpec the change that occurred */ protected void auditClass(Audit.Action action, ChangeSpec changeSpec) { - Audit[] annotations = getResourceClass().getAnnotationsByType(Audit.class); + Audit[] annotations = getResourceType().getAnnotationsByType(Audit.class); if (annotations == null) { return; } for (Audit annotation : annotations) { for (Audit.Action auditAction : annotation.action()) { - if (auditAction == action) { - LogMessage message = new LogMessage(annotation, this, Optional.ofNullable(changeSpec)); + if (auditAction == action) { // compare object reference + LogMessage message = new LogMessageImpl(annotation, this, Optional.ofNullable(changeSpec)); getRequestScope().getAuditLogger().log(message); } } @@ -1853,7 +1976,7 @@ protected void auditClass(Audit.Action action, ChangeSpec changeSpec) { */ private Collection copyCollection(final Collection collection) { final ArrayList newCollection = new ArrayList(); - if (collection == null || collection.isEmpty()) { + if (CollectionUtils.isEmpty(collection)) { return newCollection; } collection.iterator().forEachRemaining(newCollection::add); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/PersistentResourceSet.java b/elide-core/src/main/java/com/yahoo/elide/core/PersistentResourceSet.java index f870398f5b..97dea5801d 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/PersistentResourceSet.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/PersistentResourceSet.java @@ -9,6 +9,8 @@ import java.util.AbstractSet; import java.util.Iterator; +import java.util.Spliterator; +import java.util.Spliterators; /** * Stream iterable list as a set of PersistentResource. @@ -17,17 +19,20 @@ public class PersistentResourceSet extends AbstractSet> { final private PersistentResource parent; + final private String parentRelationship; final private Iterable list; final private RequestScope requestScope; - public PersistentResourceSet(PersistentResource parent, Iterable list, RequestScope requestScope) { + public PersistentResourceSet(PersistentResource parent, String parentRelationship, + Iterable list, RequestScope requestScope) { this.parent = parent; + this.parentRelationship = parentRelationship; this.list = list; this.requestScope = requestScope; } public PersistentResourceSet(Iterable list, RequestScope requestScope) { - this(null, list, requestScope); + this(null, null, list, requestScope); } @Override @@ -41,10 +46,9 @@ public boolean hasNext() { @Override public PersistentResource next() { - if (parent == null) { - return new PersistentResource<>(iterator.next(), requestScope); - } - return new PersistentResource<>(parent, iterator.next(), requestScope); + T obj = iterator.next(); + return new PersistentResource<>(obj, parent, parentRelationship, + requestScope.getUUIDFor(obj), requestScope); } }; } @@ -53,4 +57,9 @@ public PersistentResource next() { public int size() { throw new UnsupportedOperationException(); } + + @Override + public Spliterator> spliterator() { + return Spliterators.spliteratorUnknownSize(iterator(), 0); + } } 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 0c4a85f2fd..6a6364b88e 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 @@ -5,137 +5,152 @@ */ package com.yahoo.elide.core; -import com.yahoo.elide.annotation.OnCommit; -import com.yahoo.elide.audit.AuditLogger; -import com.yahoo.elide.core.exceptions.InvalidPredicateException; -import com.yahoo.elide.core.filter.dialect.MultipleFilterDialect; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.ALL_OPERATIONS; +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.audit.AuditLogger; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.exceptions.InvalidAttributeException; import com.yahoo.elide.core.filter.dialect.ParseException; -import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.dialect.jsonapi.MultipleFilterDialect; 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.lifecycle.CRUDEvent; +import com.yahoo.elide.core.lifecycle.LifecycleHookInvoker; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.PermissionExecutor; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.security.executors.ActivePermissionExecutor; +import com.yahoo.elide.core.security.executors.MultiplexPermissionExecutor; +import com.yahoo.elide.core.type.Type; import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import com.yahoo.elide.security.PermissionExecutor; -import com.yahoo.elide.security.SecurityMode; -import com.yahoo.elide.security.User; -import com.yahoo.elide.security.executors.ActivePermissionExecutor; - +import org.apache.commons.collections.MapUtils; import lombok.Getter; -import lombok.extern.slf4j.Slf4j; +import lombok.Setter; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; 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; import javax.ws.rs.core.MultivaluedMap; /** * Request scope object for relaying request-related data to various subsystems. */ -@Slf4j -public class RequestScope implements com.yahoo.elide.security.RequestScope { +public class RequestScope implements com.yahoo.elide.core.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 MultivaluedMap queryParams; @Getter private final Map> sparseFields; - @Getter private final Pagination pagination; - @Getter private final Sorting sorting; - @Getter private final SecurityMode securityMode; + @Getter private final Map> requestHeaders; @Getter private final PermissionExecutor permissionExecutor; @Getter private final ObjectEntityCache objectEntityCache; @Getter private final Set newPersistentResources; @Getter private final LinkedHashSet dirtyResources; + @Getter private final LinkedHashSet deletedResources; + @Getter private final String baseUrlEndPoint; @Getter private final String path; - private final boolean useFilterExpressions; - private final MultipleFilterDialect filterDialect; + @Getter private final ElideSettings elideSettings; + @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; + @Getter private final UUID requestId; private final Map expressionsByType; + private LinkedHashSet eventQueue; + /* Used to filter across heterogeneous types during the first load */ private FilterExpression globalFilterExpression; - final private transient LinkedHashSet commitTriggers; - /** - * Create a new RequestScope. + * Create a new RequestScope with specified update status code. + * + * @param baseUrlEndPoint base URL with prefix endpoint * @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 - * @param dictionary the entity dictionary - * @param mapper converts JsonApiDocuments to raw JSON - * @param auditLogger logger for this request * @param queryParams the query parameters - * @param securityMode the current security mode - * @param permissionExecutorGenerator the user-provided function that will generate a permissionExecutor + * @param requestHeaders the requestHeaders + * @param elideSettings Elide settings object */ - public RequestScope(String path, + public RequestScope(String baseUrlEndPoint, + String path, + String apiVersion, JsonApiDocument jsonApiDocument, DataStoreTransaction transaction, User user, - EntityDictionary dictionary, - JsonApiMapper mapper, - AuditLogger auditLogger, MultivaluedMap queryParams, - SecurityMode securityMode, - Function permissionExecutorGenerator, - MultipleFilterDialect filterDialect, - boolean useFilterExpressions) { + Map> requestHeaders, + UUID requestId, + ElideSettings elideSettings) { + this.apiVersion = apiVersion; + this.eventQueue = new LinkedHashSet<>(); + this.path = path; + this.baseUrlEndPoint = baseUrlEndPoint; this.jsonApiDocument = jsonApiDocument; this.transaction = transaction; this.user = user; - this.dictionary = dictionary; - this.mapper = mapper; - this.auditLogger = auditLogger; - this.securityMode = securityMode; - this.filterDialect = filterDialect; - this.useFilterExpressions = useFilterExpressions; + this.dictionary = elideSettings.getDictionary(); + this.mapper = elideSettings.getMapper(); + this.auditLogger = elideSettings.getAuditLogger(); + this.filterDialect = new MultipleFilterDialect(elideSettings.getJoinFilterDialects(), + elideSettings.getSubqueryFilterDialects()); + this.elideSettings = elideSettings; + this.updateStatusCode = elideSettings.getUpdateStatusCode(); this.globalFilterExpression = null; this.expressionsByType = new HashMap<>(); this.objectEntityCache = new ObjectEntityCache(); this.newPersistentResources = new LinkedHashSet<>(); this.dirtyResources = new LinkedHashSet<>(); - this.commitTriggers = new LinkedHashSet<>(); + this.deletedResources = new LinkedHashSet<>(); + this.requestId = requestId; + this.queryParams = queryParams == null ? new MultivaluedHashMap<>() : queryParams; - this.permissionExecutor = (permissionExecutorGenerator == null) - ? new ActivePermissionExecutor(this) - : permissionExecutorGenerator.apply(this); + this.requestHeaders = MapUtils.isEmpty(requestHeaders) + ? Collections.emptyMap() + : requestHeaders; - this.queryParams = (queryParams == null || queryParams.size() == 0) - ? Optional.empty() - : Optional.of(queryParams); + this.sparseFields = parseSparseFields(getQueryParams()); - if (this.queryParams.isPresent()) { + if (!this.queryParams.isEmpty()) { /* Extract any query param that starts with 'filter' */ - MultivaluedMap filterParams = getFilterParams(queryParams); + MultivaluedMap filterParams = getFilterParams(this.queryParams); String errorMessage = ""; if (! filterParams.isEmpty()) { /* 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 */ @@ -149,145 +164,64 @@ public RequestScope(String path, errorMessage = errorMessage + "\n" + e.getMessage(); } - throw new InvalidPredicateException(errorMessage); + throw new BadRequestException(errorMessage, e); } } } - - this.sparseFields = parseSparseFields(queryParams); - this.sorting = Sorting.parseQueryParams(queryParams); - this.pagination = Pagination.parseQueryParams(queryParams); - } else { - this.sparseFields = Collections.emptyMap(); - this.sorting = Sorting.getDefaultEmptyInstance(); - this.pagination = Pagination.getDefaultPagination(); - } - - if (transaction instanceof RequestScopedTransaction) { - ((RequestScopedTransaction) transaction).setRequestScope(this); } - } - public RequestScope(String path, - JsonApiDocument jsonApiDocument, - DataStoreTransaction transaction, - User user, - EntityDictionary dictionary, - JsonApiMapper mapper, - AuditLogger auditLogger, - SecurityMode securityMode, - Function permissionExecutor) { - this( - path, - jsonApiDocument, - transaction, - user, - dictionary, - mapper, - auditLogger, - null, - securityMode, - permissionExecutor, - new MultipleFilterDialect(dictionary), - false - ); - } - - public RequestScope(String path, - JsonApiDocument jsonApiDocument, - DataStoreTransaction transaction, - User user, - EntityDictionary dictionary, - JsonApiMapper mapper, - AuditLogger auditLogger, - MultivaluedMap queryParams) { - this( - path, - jsonApiDocument, - transaction, - user, - dictionary, - mapper, - auditLogger, - queryParams, - SecurityMode.SECURITY_ACTIVE, - null, - new MultipleFilterDialect(dictionary), - false - ); - } - - public RequestScope(String path, - JsonApiDocument jsonApiDocument, - DataStoreTransaction transaction, - User user, - EntityDictionary dictionary, - JsonApiMapper mapper, - AuditLogger auditLogger) { - this( - path, - jsonApiDocument, - transaction, - user, - dictionary, - mapper, - auditLogger, - null, - SecurityMode.SECURITY_ACTIVE, - null, - new MultipleFilterDialect(dictionary), - false + Function permissionExecutorGenerator = elideSettings.getPermissionExecutor(); + this.permissionExecutor = new MultiplexPermissionExecutor( + dictionary.buildPermissionExecutors(this), + (permissionExecutorGenerator == null) + ? new ActivePermissionExecutor(this) + : permissionExecutorGenerator.apply(this), + dictionary ); } - /** - * Outer RequestScope constructor for use by Patch Extension. - * - * @param transaction the transaction - * @param user the user - * @param dictionary the dictionary - * @param mapper the mapper - * @param auditLogger the logger - */ - protected RequestScope(DataStoreTransaction transaction, - User user, - EntityDictionary dictionary, - JsonApiMapper mapper, - AuditLogger auditLogger) { - this(null, null, transaction, user, dictionary, mapper, auditLogger); - } - /** * 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.baseUrlEndPoint = outerRequestScope.baseUrlEndPoint; + this.apiVersion = apiVersion; this.path = path; this.transaction = outerRequestScope.transaction; this.user = outerRequestScope.user; this.dictionary = outerRequestScope.dictionary; this.mapper = outerRequestScope.mapper; this.auditLogger = outerRequestScope.auditLogger; - this.queryParams = Optional.empty(); - this.sparseFields = Collections.emptyMap(); - this.sorting = Sorting.getDefaultEmptyInstance(); - this.pagination = Pagination.getDefaultPagination(); + this.queryParams = new MultivaluedHashMap<>(); + this.requestHeaders = new MultivaluedHashMap<>(); + this.requestHeaders.putAll(outerRequestScope.requestHeaders); this.objectEntityCache = outerRequestScope.objectEntityCache; - this.securityMode = outerRequestScope.securityMode; this.newPersistentResources = outerRequestScope.newPersistentResources; - this.commitTriggers = outerRequestScope.commitTriggers; this.permissionExecutor = outerRequestScope.getPermissionExecutor(); this.dirtyResources = outerRequestScope.dirtyResources; + this.deletedResources = outerRequestScope.deletedResources; this.filterDialect = outerRequestScope.filterDialect; this.expressionsByType = outerRequestScope.expressionsByType; - this.useFilterExpressions = outerRequestScope.useFilterExpressions; + this.elideSettings = outerRequestScope.elideSettings; + this.eventQueue = outerRequestScope.eventQueue; + this.updateStatusCode = outerRequestScope.updateStatusCode; + this.requestId = outerRequestScope.requestId; + this.sparseFields = outerRequestScope.sparseFields; } - public Set getNewResources() { - return (Set) (Set) newPersistentResources; + public Set getNewResources() { + return (Set) newPersistentResources; + } + + public boolean isNewResource(Object entity) { + return newPersistentResources.stream().anyMatch(r -> r.getObject() == entity); } /** @@ -295,7 +229,7 @@ public Set getNewResources() { * @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()) { @@ -326,39 +260,50 @@ 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(Type entityClass) { + return Optional.ofNullable(expressionsByType.get(dictionary.getJsonAliasFor(entityClass))); + } + /** * Get the global/cross-type filter expression. - * @param loadClass + * @param loadClass Entity class * @return The global filter expression evaluated at the first load */ - public Optional getLoadFilterExpression(Class loadClass) { - Optional permissionFilter; - permissionFilter = getPermissionExecutor().getReadPermissionFilter(loadClass); - Optional globalFilterExpressionOptional = null; + public Optional getLoadFilterExpression(Type loadClass) { + Optional filterExpression; if (globalFilterExpression == null) { String typeName = dictionary.getJsonAliasFor(loadClass); - globalFilterExpressionOptional = getFilterExpressionByType(typeName); + filterExpression = getFilterExpressionByType(typeName); } else { - globalFilterExpressionOptional = Optional.of(globalFilterExpression); + filterExpression = Optional.of(globalFilterExpression); } + return filterExpression; + } - if (globalFilterExpressionOptional.isPresent() && permissionFilter.isPresent()) { - return Optional.of(new AndFilterExpression(globalFilterExpressionOptional.get(), - permissionFilter.get())); - } - else if (globalFilterExpressionOptional.isPresent()) { - return globalFilterExpressionOptional; - } - else if (permissionFilter.isPresent()) { - return permissionFilter; - } else { - return Optional.empty(); + /** + * 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(Type parentType, String relationName) { + final Type entityClass = dictionary.getParameterizedType(parentType, relationName); + if (entityClass == null) { + throw new InvalidAttributeException(relationName, dictionary.getJsonAliasFor(parentType)); } + + final String valType = dictionary.getJsonAliasFor(entityClass); + return getFilterExpressionByType(valType); } /** * Extracts any query params that start with 'filter'. - * @param queryParams + * @param queryParams request query params * @return extracted filter params */ private static MultivaluedMap getFilterParams(MultivaluedMap queryParams) { @@ -366,40 +311,157 @@ private static MultivaluedMap getFilterParams(MultivaluedMap entry.getKey().startsWith("filter")) - .forEach((entry) -> { - returnMap.put(entry.getKey(), entry.getValue()); - }); + .filter(entry -> entry.getKey().startsWith("filter")) + .forEach(entry -> returnMap.put(entry.getKey(), entry.getValue())); return returnMap; } + private void notifySubscribers( + LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase + ) { + LifecycleHookInvoker invoker = new LifecycleHookInvoker(dictionary, operation, phase); + + this.eventQueue.stream() + .filter(event -> event.getEventType().equals(operation)) + .forEach(event -> { + invoker.onNext(event); + }); + } + /** - * run any deferred post-commit triggers. - * - * @see com.yahoo.elide.annotation.CreatePermission + * Run queued pre-security lifecycle triggers. */ - public void runCommitTriggers() { - new ArrayList<>(commitTriggers).forEach(Runnable::run); - commitTriggers.clear(); + public void runQueuedPreSecurityTriggers() { + notifySubscribers(LifeCycleHookBinding.Operation.CREATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY); } - public void queueCommitTrigger(PersistentResource resource) { - queueCommitTrigger(resource, ""); + /** + * Run queued pre-flush lifecycle triggers. + */ + public void runQueuedPreFlushTriggers() { + runQueuedPreFlushTriggers(ALL_OPERATIONS); } - public void queueCommitTrigger(PersistentResource resource, String fieldName) { - commitTriggers.add(() -> resource.runTriggers(OnCommit.class, fieldName)); + /** + * Run queued pre-flush lifecycle triggers. + * @param operations List of operations to run pre-flush triggers for. + */ + public void runQueuedPreFlushTriggers(LifeCycleHookBinding.Operation[] operations) { + for (LifeCycleHookBinding.Operation op : operations) { + notifySubscribers(op, LifeCycleHookBinding.TransactionPhase.PREFLUSH); + } } - public void saveObjects() { - dirtyResources.stream().map(PersistentResource::getObject).forEach(transaction::save); + /** + * Run queued pre-commit lifecycle triggers. + */ + public void runQueuedPreCommitTriggers() { + for (LifeCycleHookBinding.Operation op : ALL_OPERATIONS) { + notifySubscribers(op, LifeCycleHookBinding.TransactionPhase.PRECOMMIT); + } } /** - * Whether or not to use Elide 3.0 filter expressions for DataStoreTransaction calls - * @return + * Run queued post-commit lifecycle triggers. */ - public boolean useFilterExpressions() { - return useFilterExpressions; + public void runQueuedPostCommitTriggers() { + for (LifeCycleHookBinding.Operation op : ALL_OPERATIONS) { + notifySubscribers(op, LifeCycleHookBinding.TransactionPhase.POSTCOMMIT); + } + } + + /** + * Publishes a lifecycle event to all listeners. + * + * @param resource Resource on which to execute trigger + * @param crudAction CRUD action + */ + protected void publishLifecycleEvent(PersistentResource resource, LifeCycleHookBinding.Operation crudAction) { + publishLifecycleEvent(new CRUDEvent(crudAction, resource, PersistentResource.CLASS_NO_FIELD, Optional.empty())); + } + + /** + * Publishes a lifecycle event to all listeners. + * + * @param resource Resource on which to execute trigger + * @param fieldName Field name for which to specify trigger + * @param crudAction CRUD Action + * @param changeSpec Optional ChangeSpec to pass to the lifecycle hook + */ + protected void publishLifecycleEvent(PersistentResource resource, + String fieldName, + LifeCycleHookBinding.Operation crudAction, + Optional changeSpec) { + publishLifecycleEvent(new CRUDEvent(crudAction, resource, fieldName, changeSpec)); + } + + protected void publishLifecycleEvent(CRUDEvent event) { + if (! eventQueue.contains(event)) { + if (event.getEventType().equals(LifeCycleHookBinding.Operation.DELETE) + || event.getEventType().equals(LifeCycleHookBinding.Operation.UPDATE)) { + + LifecycleHookInvoker invoker = new LifecycleHookInvoker(dictionary, + event.getEventType(), + LifeCycleHookBinding.TransactionPhase.PRESECURITY); + + invoker.onNext(event); + } + + eventQueue.add(event); + } + } + + public void saveOrCreateObjects() { + dirtyResources.removeAll(newPersistentResources); + // Delete has already been called on these objects + dirtyResources.removeAll(deletedResources); + newPersistentResources + .stream() + .map(PersistentResource::getObject) + .forEach(s -> transaction.createObject(s, this)); + dirtyResources.stream().map(PersistentResource::getObject).forEach(obj -> transaction.save(obj, this)); + } + + public String getUUIDFor(Object o) { + return objectEntityCache.getUUID(o); + } + + public Object getObjectById(Type type, String id) { + Type boundType = dictionary.lookupBoundClass(type); + + Object result = objectEntityCache.get(boundType.getName(), id); + + // Check inheritance too + Iterator> it = dictionary.getSubclassingEntities(boundType).iterator(); + while (result == null && it.hasNext()) { + String newType = getInheritanceKey(it.next().getName(), boundType.getName()); + result = objectEntityCache.get(newType, id); + } + + return result; + } + + public void setUUIDForObject(Type type, String id, Object object) { + Type boundType = dictionary.lookupBoundClass(type); + + objectEntityCache.put(boundType.getName(), id, object); + + // Insert for all inherited entities as well + dictionary.getSuperClassEntities(type).stream() + .map(i -> getInheritanceKey(boundType.getName(), i.getName())) + .forEach((newType) -> objectEntityCache.put(newType, id, object)); + } + + private String getInheritanceKey(String subClass, String superClass) { + return subClass + "!" + superClass; + } + + @Override + public String getRequestHeaderByName(String headerName) { + if (this.requestHeaders.get(headerName) == null) { + return null; + } + return this.requestHeaders.get(headerName).get(0); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/RequestScopedTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/RequestScopedTransaction.java deleted file mode 100644 index 814df22d37..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/RequestScopedTransaction.java +++ /dev/null @@ -1,14 +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; - -/** - * Scope aware transaction - */ -public interface RequestScopedTransaction extends DataStoreTransaction { - - void setRequestScope(RequestScope requestScope); -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/ResourceLineage.java b/elide-core/src/main/java/com/yahoo/elide/core/ResourceLineage.java index 9ac777c0e9..67fb822878 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/ResourceLineage.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/ResourceLineage.java @@ -7,9 +7,11 @@ import org.apache.commons.collections4.map.LinkedMap; import org.apache.commons.lang3.builder.HashCodeBuilder; +import lombok.Value; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedList; import java.util.List; /** @@ -19,37 +21,37 @@ */ public class ResourceLineage { private final LinkedMap> resourceMap; + private final List resourcePath; /** - * Empty lineage for objects rooted in the URL. + * A node in the lineage that includes the resource and the link to the next node. */ - public ResourceLineage() { - resourceMap = new LinkedMap<>(); + @Value + public static class LineagePath { + private PersistentResource resource; + + //The relationship that links this path element to the next. + private String relationship; } /** - * Extends a lineage with a new resource. - * @param sharedLineage the shared lineage - * @param next the next + * Empty lineage for objects rooted in the URL. */ - public ResourceLineage(ResourceLineage sharedLineage, PersistentResource next) { - resourceMap = new LinkedMap<>(sharedLineage.resourceMap); - addRecord(next); + public ResourceLineage() { + resourceMap = new LinkedMap<>(); + resourcePath = new LinkedList<>(); } /** - * Extends a lineage with a new resource that has an alias. An alias is useful if - * there are two objects in the lineage with the same type or class. The resource - * lineage does not allow two resources to have the same name. As long as one of the two - * resources can be given an alias (by annotation), then two objects with the same type/class - * can exist in the lineage. This case is likely to be uncommon. + * Extends a lineage with a new resource. * @param sharedLineage the shared lineage * @param next the next - * @param nextAlias the next alias + * @param relationship The relationship name that links the lineage with the next element. */ - public ResourceLineage(ResourceLineage sharedLineage, PersistentResource next, String nextAlias) { + public ResourceLineage(ResourceLineage sharedLineage, PersistentResource next, String relationship) { resourceMap = new LinkedMap<>(sharedLineage.resourceMap); - addRecord(next, nextAlias); + resourcePath = new LinkedList<>(sharedLineage.resourcePath); + addRecord(next, relationship); } /** @@ -63,6 +65,18 @@ public List getRecord(String name) { return list == null ? Collections.emptyList() : list; } + /** + * Returns the immediate parent resource if one exists. + * @return the parent or null if there is no parent. + */ + public PersistentResource getParent() { + if (resourcePath.isEmpty()) { + return null; + } + + return resourcePath.get(resourcePath.size() - 1).resource; + } + /** * Gets keys. * @@ -72,8 +86,8 @@ public List getKeys() { return resourceMap.asList(); } - private void addRecord(PersistentResource latest) { - addRecord(latest, latest.getType()); + public List getResourcePath() { + return resourcePath; } @Override @@ -98,10 +112,11 @@ public boolean equals(Object input) { return false; } } - return true; + return other.resourcePath.equals(this.resourcePath); } - private void addRecord(PersistentResource latest, String alias) { + private void addRecord(PersistentResource latest, String relationship) { + String alias = latest.getTypeName(); List resources; if (resourceMap.containsKey(alias)) { resources = resourceMap.get(alias); @@ -110,5 +125,6 @@ private void addRecord(PersistentResource latest, String alias) { resourceMap.put(alias, resources); } resources.add(latest); + resourcePath.add(new ResourceLineage.LineagePath(latest, relationship)); } } 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..ab279214e7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/TransactionRegistry.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.core; + +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import lombok.Getter; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** +* Transaction Registry class. +*/ +@Getter +public class TransactionRegistry { + private Map transactionMap = new ConcurrentHashMap<>(); + + public Map getRunningTransactions() { + return transactionMap; + } + + public DataStoreTransaction getRunningTransaction(UUID requestId) { + return transactionMap.get(requestId); + } + + public void addRunningTransaction(UUID requestId, DataStoreTransaction tx) { + transactionMap.put(requestId, tx); + } + + public void removeRunningTransaction(UUID requestId) { + transactionMap.remove(requestId); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/audit/AuditLogger.java b/elide-core/src/main/java/com/yahoo/elide/core/audit/AuditLogger.java new file mode 100644 index 0000000000..1407484982 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/audit/AuditLogger.java @@ -0,0 +1,33 @@ +/* + * 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.audit; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Base Audit Logger + *

+ * This class uses ThreadLocal list to be thread safe. + */ +public abstract class AuditLogger { + protected static final ThreadLocal> MESSAGES = + ThreadLocal.withInitial(ArrayList::new); + + public void log(LogMessage message) { + MESSAGES.get().add(message); + } + + public abstract void commit() throws IOException; + + public void clear() { + List remainingMessages = MESSAGES.get(); + if (remainingMessages != null) { + remainingMessages.clear(); + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/audit/InvalidSyntaxException.java b/elide-core/src/main/java/com/yahoo/elide/core/audit/InvalidSyntaxException.java similarity index 93% rename from elide-core/src/main/java/com/yahoo/elide/audit/InvalidSyntaxException.java rename to elide-core/src/main/java/com/yahoo/elide/core/audit/InvalidSyntaxException.java index f14290d848..a3beb1c7f8 100644 --- a/elide-core/src/main/java/com/yahoo/elide/audit/InvalidSyntaxException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/audit/InvalidSyntaxException.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.audit; +package com.yahoo.elide.core.audit; /** * Thrown if audit has been configured incorrectly by the programmer. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/audit/LogMessage.java b/elide-core/src/main/java/com/yahoo/elide/core/audit/LogMessage.java new file mode 100644 index 0000000000..4ce04d91e7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/audit/LogMessage.java @@ -0,0 +1,55 @@ +/* + * 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.audit; + +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.PersistentResource; +import com.yahoo.elide.core.security.User; + +import java.util.Optional; + +/** + * Elide audit entity for a CRUD action. + */ +public interface LogMessage { + + /** + * Gets message. + * + * @return the message + */ + public String getMessage(); + + /** + * 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 int getOperationCode(); + + /** + * Get the user principal associated with the request. + * + * @return the user principal. + */ + public User getUser(); + + /** + * Get the change specification + * + * @return the change specification. + */ + public Optional getChangeSpec(); + + /** + * Get the resource that was manipulated. + * + * @return the resource. + */ + public PersistentResource getPersistentResource(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/audit/LogMessageImpl.java b/elide-core/src/main/java/com/yahoo/elide/core/audit/LogMessageImpl.java new file mode 100644 index 0000000000..df29309ac8 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/audit/LogMessageImpl.java @@ -0,0 +1,174 @@ +/* + * 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.audit; + +import com.yahoo.elide.annotation.Audit; +import com.yahoo.elide.core.ResourceLineage; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.PersistentResource; +import com.yahoo.elide.core.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, null); + + 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/core/audit/Slf4jLogger.java similarity index 81% rename from elide-core/src/main/java/com/yahoo/elide/audit/Slf4jLogger.java rename to elide-core/src/main/java/com/yahoo/elide/core/audit/Slf4jLogger.java index c291696f83..8ce7bdf50e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/audit/Slf4jLogger.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/audit/Slf4jLogger.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.audit; +package com.yahoo.elide.core.audit; import lombok.extern.slf4j.Slf4j; @@ -18,11 +18,11 @@ public class Slf4jLogger extends AuditLogger { @Override public void commit() throws IOException { try { - for (LogMessage message : messages.get()) { + for (LogMessage message : MESSAGES.get()) { log.info("{} {} {}", System.currentTimeMillis(), message.getOperationCode(), message.getMessage()); } } finally { - messages.get().clear(); + MESSAGES.get().clear(); } } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/DataStore.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStore.java similarity index 88% rename from elide-core/src/main/java/com/yahoo/elide/core/DataStore.java rename to elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStore.java index b55049793d..c40dbe09cd 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/DataStore.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStore.java @@ -3,7 +3,9 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.core; +package com.yahoo.elide.core.datastore; + +import com.yahoo.elide.core.dictionary.EntityDictionary; /** * Database interface library. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterable.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterable.java new file mode 100644 index 0000000000..88c647dfd3 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterable.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.datastore; + +import java.util.Iterator; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.Consumer; + +/** + * Returns data loaded from a DataStore. Wraps an iterable but also communicates to Elide + * if the framework needs to filter, sort, or paginate the iterable in memory before returning to the client. + * @param The type being iterated over. + */ +public interface DataStoreIterable extends Iterable { + + /** + * Returns the underlying iterable. + * @return The underlying iterable. + */ + Iterable getWrappedIterable(); + + @Override + default Iterator iterator() { + return getWrappedIterable().iterator(); + } + + @Override + default void forEach(Consumer action) { + getWrappedIterable().forEach(action); + } + + @Override + default Spliterator spliterator() { + return Spliterators.spliteratorUnknownSize(iterator(), 0); + } + + /** + * Whether the iterable should be filtered in memory. + * @return true if the iterable needs sorting in memory. false otherwise. + */ + default boolean needsInMemoryFilter() { + return false; + } + + /** + * Whether the iterable should be sorted in memory. + * @return true if the iterable needs sorting in memory. false otherwise. + */ + default boolean needsInMemorySort() { + return false; + } + + /** + * Whether the iterable should be paginated in memory. + * @return true if the iterable needs pagination in memory. false otherwise. + */ + default boolean needsInMemoryPagination() { + return false; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterableBuilder.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterableBuilder.java new file mode 100644 index 0000000000..ba12743df0 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterableBuilder.java @@ -0,0 +1,109 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.datastore; + +import java.util.ArrayList; + +/** + * Constructs DataStoreIterables. + * @param + */ +public class DataStoreIterableBuilder { + + private boolean filterInMemory = false; + private boolean sortInMemory = false; + private boolean paginateInMemory = false; + private final Iterable wrapped; + + /** + * Constructor. + */ + public DataStoreIterableBuilder() { + this.wrapped = new ArrayList<>(); + } + + /** + * Constructor. + * @param wrapped Required iterable to wrap. + */ + public DataStoreIterableBuilder(Iterable wrapped) { + if (wrapped == null) { + this.wrapped = new ArrayList<>(); + } else { + this.wrapped = wrapped; + } + } + + /** + * Filter the iterable in memory. + * @param filterInMemory true to filter in memory. + * @return the builder. + */ + public DataStoreIterableBuilder filterInMemory(boolean filterInMemory) { + this.filterInMemory = filterInMemory; + return this; + } + + /** + * Sorts the iterable in memory. + * @param sortInMemory true to sort in memory. + * @return the builder. + */ + public DataStoreIterableBuilder sortInMemory(boolean sortInMemory) { + this.sortInMemory = sortInMemory; + return this; + } + + /** + * Paginates the iterable in memory. + * @param paginateInMemory true to paginate in memory. + * @return the builder. + */ + public DataStoreIterableBuilder paginateInMemory(boolean paginateInMemory) { + this.paginateInMemory = paginateInMemory; + return this; + } + + /** + * Filter, sort, and paginate in memory. + * @return the builder. + */ + public DataStoreIterableBuilder allInMemory() { + this.filterInMemory = true; + this.sortInMemory = true; + this.paginateInMemory = true; + return this; + } + + /** + * Constructs the DataStoreIterable. + * @return the new iterable. + */ + public DataStoreIterable build() { + return new DataStoreIterable() { + @Override + public Iterable getWrappedIterable() { + return wrapped; + } + + @Override + public boolean needsInMemoryFilter() { + return filterInMemory; + } + + @Override + public boolean needsInMemorySort() { + return sortInMemory; + } + + @Override + public boolean needsInMemoryPagination() { + return paginateInMemory; + } + }; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreTransaction.java new file mode 100644 index 0000000000..22c5da62d3 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreTransaction.java @@ -0,0 +1,300 @@ +/* + * 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.datastore; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.Injector; +import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException; +import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.InPredicate; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.core.type.ParameterizedModel; +import com.yahoo.elide.core.type.Type; + +import java.io.Closeable; +import java.io.Serializable; +import java.util.Iterator; +import java.util.Set; +/** + * Wraps the Database Transaction type. + */ +public interface DataStoreTransaction extends Closeable { + /** + * Save the updated object. + * + * @param entity - the object to save. + * @param scope - contains request level metadata. + * @param The model type being saved. + */ + void save(T entity, RequestScope scope); + + /** + * Delete the object. + * + * @param entity - the object to delete. + * @param scope - contains request level metadata. + * @param The model type being deleted. + */ + void delete(T entity, RequestScope scope); + + /** + * Write any outstanding entities before processing response. + * + * @param scope the request scope for the current request + */ + void flush(RequestScope scope); + + /** + * End the current transaction. + * + * @param scope the request scope for the current request + */ + void commit(RequestScope scope); + + /** + * Called before commit checks are evaluated and before save, flush, and commit are called. + * The sequence goes: + * 1. transaction.preCommit(); + * 2. Invoke security checks evaluated at commit time + * 3. transaction.save(...); - Invoked for every object which changed in the transaction. + * 4. transaction.flush(); + * 5. transaction.commit(); + */ + default void preCommit(RequestScope scope) { + } + + /** + * Elide will create and populate the object with the attributes and relationships before + * calling this method. Operation security checks will be evaluated before invocation but commit + * security checks will be called later immediately prior to `commit` being called. + * + * @param entity - the object to create in the data store. + * @param scope - contains request level metadata. + * @param The model type being created. + */ + void createObject(T entity, RequestScope scope); + + /** + * Create a new instance of an object. + * + * @param entityClass the class + * @param the model type to create + * @return a new instance of type T + */ + default T createNewObject(Type entityClass, RequestScope scope) { + Injector injector = scope.getDictionary().getInjector(); + + T obj; + if (entityClass.getUnderlyingClass().isPresent()) { + obj = injector.instantiate(entityClass.getUnderlyingClass().get()); + } else { + try { + obj = entityClass.newInstance(); + } catch (java.lang.InstantiationException | IllegalAccessException e) { + obj = null; + } + } + return obj; + } + + /** + * 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 entityProjection the collection to load. + * @param id - the ID of the object to load. + * @param scope - the current request scope + * @param The model type being loaded. + * It is optional for the data store to attempt evaluation. + * @return the loaded object if it exists AND any provided security filters pass. + */ + default T loadObject(EntityProjection entityProjection, + Serializable id, + RequestScope scope) { + Type entityClass = entityProjection.getType(); + FilterExpression filterExpression = entityProjection.getFilterExpression(); + + EntityDictionary dictionary = scope.getDictionary(); + Type idType = dictionary.getIdType(entityClass); + String idField = dictionary.getIdFieldName(entityClass); + FilterExpression idFilter = new InPredicate( + new Path.PathElement(entityClass, idType, idField), + id + ); + 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()) { + T obj = it.next(); + if (!it.hasNext()) { + return obj; + } + + //Multiple objects with the same ID. + throw new InvalidObjectIdentifierException(id.toString(), dictionary.getJsonAliasFor(entityClass)); + } + return null; + } + + /** + * Loads a collection of objects. + * + * @param entityProjection - the class to load + * @param scope - contains request level metadata. + * @param - The model type being loaded. + * @return a collection of the loaded objects + */ + DataStoreIterable loadObjects( + EntityProjection entityProjection, + RequestScope scope); + + /** + * Retrieve a to-many relation from an object. + * + * @param relationTx - The datastore that governs objects of the relationhip's type. + * @param entity - The object which owns the relationship. + * @param relationship - the relationship to fetch. + * @param scope - contains request level metadata. + * @param - The model type which owns the relationship. + * @param - The model type of the relationship. + * @return the object in the relation + */ + default DataStoreIterable getToManyRelation( + DataStoreTransaction relationTx, + T entity, + Relationship relationship, + RequestScope scope) { + + return new DataStoreIterableBuilder( + (Iterable) PersistentResource.getValue(entity, relationship.getName(), scope)).allInMemory().build(); + } + + /** + * Retrieve a to-one relation from an object. + * + * @param relationTx - The datastore that governs objects of the relationhip's type. + * @param entity - The object which owns the relationship. + * @param relationship - the relationship to fetch. + * @param scope - contains request level metadata. + * @param - The model type which owns the relationship. + * @param - The model type of the relationship. + * @return the object in the relation + */ + default R getToOneRelation( + DataStoreTransaction relationTx, + T entity, + Relationship relationship, + RequestScope scope) { + + return (R) PersistentResource.getValue(entity, relationship.getName(), scope); + } + + /** + * Elide core will update the in memory representation of the objects to the requested state. + * These functions allow a data store to optionally persist the relationship if needed. + * + * @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 newRelationships - the set of the added relationship to the collection. + * @param deletedRelationships - the set of the deleted relationship to the collection. + * @param scope - contains request level metadata. + * @param - The model type which owns the relationship. + * @param - The model type of the relationship. + */ + default void updateToManyRelation(DataStoreTransaction relationTx, + T entity, + String relationName, + Set newRelationships, + Set deletedRelationships, + RequestScope scope) { + } + + /** + * Elide core will update the in memory representation of the objects to the requested state. + * These functions allow a data store to optionally persist the relationship if needed. + * + * @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 relationshipValue - the new value of the updated one-to-one relationship + * @param scope - contains request level metadata. + * @param - The model type which owns the relationship. + * @param - The model type of the relationship. + */ + default void updateToOneRelation(DataStoreTransaction relationTx, + T entity, + String relationName, + R relationshipValue, + RequestScope scope) { + } + + /** + * Get an attribute from an object. + * + * @param entity - The object which owns the attribute. + * @param attribute - The attribute to fetch + * @param scope - contains request level metadata. + * @param - The model type which owns the attribute. + * @param - The type of the attribute. + * @return the value of the attribute + */ + default R getAttribute(T entity, + Attribute attribute, + RequestScope scope) { + if (entity instanceof ParameterizedModel) { + return ((ParameterizedModel) entity).invoke(attribute); + } + return (R) PersistentResource.getValue(entity, attribute.getName(), scope); + + } + + /** + * Set an attribute on an object in the data store. + *

+ * Elide core will update the in memory representation of the objects to the requested state. + * This function allow a data store to optionally persist the attribute if needed. + * + * @param entity - The object which owns the attribute. + * @param attribute - the attribute to set. + * @param scope - contains request level metadata. + * @param - The model type which owns the attribute. + */ + default void setAttribute(T entity, + Attribute attribute, + RequestScope scope) { + } + + /** + * Cancel running transaction. + * Implementation must be thread-safe. + * @param scope contains request level metadata. + */ + void cancel(RequestScope scope); + + /** + * Returns a data store transaction property. + * @param propertyName The property name. + * @param The type of the property. + * @return The property. + */ + default T getProperty(String propertyName) { + return null; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/FilteredIterator.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/FilteredIterator.java new file mode 100644 index 0000000000..d8d479e95b --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/FilteredIterator.java @@ -0,0 +1,77 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.datastore.inmemory; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.expression.InMemoryFilterExecutor; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.function.Predicate; + +/** + * An iterator which filters another iterator by an Elide filter expression. + * @param The type being iterated over. + */ +public class FilteredIterator implements Iterator { + + private Iterator wrapped; + private Predicate predicate; + + private T next; + + /** + * Constructor. + * @param filterExpression The filter expression to filter on. + * @param scope Request scope. + * @param wrapped The wrapped iterator. + */ + public FilteredIterator(FilterExpression filterExpression, RequestScope scope, Iterator wrapped) { + this.wrapped = wrapped; + InMemoryFilterExecutor executor = new InMemoryFilterExecutor(scope); + + predicate = filterExpression.accept(executor); + } + + @Override + public boolean hasNext() { + try { + next = next(); + } catch (NoSuchElementException e) { + return false; + } + + return true; + } + + @Override + public T next() { + if (next != null) { + T result = next; + next = null; + return result; + } + + while (next == null && wrapped.hasNext()) { + try { + next = wrapped.next(); + } catch (NoSuchElementException e) { + next = null; + } + if (next == null || ! predicate.test(next)) { + next = null; + } + } + + if (next == null) { + throw new NoSuchElementException(); + } + + return next; + } +} 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 new file mode 100644 index 0000000000..d26f1440f0 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapDataStore.java @@ -0,0 +1,110 @@ +/* + * 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.datastore.inmemory; + +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.test.DataStoreTestHarness; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ClassScanner; +import com.google.common.collect.Sets; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Simple in-memory only database. + */ +public class HashMapDataStore implements DataStore, DataStoreTestHarness { + protected final Map, Map> dataStore = Collections.synchronizedMap(new HashMap<>()); + @Getter protected EntityDictionary dictionary; + @Getter private final ConcurrentHashMap, AtomicLong> typeIds = new ConcurrentHashMap<>(); + + public HashMapDataStore(ClassScanner scanner, Package beanPackage) { + this(scanner, Sets.newHashSet(beanPackage)); + } + + public HashMapDataStore(ClassScanner scanner, Set beanPackages) { + for (Package beanPackage : beanPackages) { + scanner.getAllClasses(beanPackage.getName()).stream() + .map(ClassType::new) + .filter(modelType -> EntityDictionary.getFirstAnnotation(modelType, + Arrays.asList(Include.class, Exclude.class)) instanceof Include) + .forEach(modelType -> dataStore.put(modelType, + Collections.synchronizedMap(new LinkedHashMap<>()))); + } + } + + public HashMapDataStore(Collection> beanClasses) { + beanClasses.stream() + .map(ClassType::new) + .filter(modelType -> EntityDictionary.getFirstAnnotation(modelType, + Arrays.asList(Include.class, Exclude.class)) instanceof Include) + .forEach(modelType -> dataStore.put(modelType, + Collections.synchronizedMap(new LinkedHashMap<>()))); + } + + @Override + public void populateEntityDictionary(EntityDictionary dictionary) { + for (Type clazz : dataStore.keySet()) { + dictionary.bindEntity(clazz); + } + + this.dictionary = dictionary; + } + + @Override + public DataStoreTransaction beginTransaction() { + return new HashMapStoreTransaction(dataStore, dictionary, typeIds); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("Data store contents "); + for (Map.Entry, Map> dse : dataStore.entrySet()) { + sb.append("\n Table ").append(dse.getKey()).append(" contents \n"); + for (Map.Entry e : dse.getValue().entrySet()) { + sb.append(" Id: ").append(e.getKey()).append(" Value: ").append(e.getValue()); + } + } + return sb.toString(); + } + + @Override + public DataStore getDataStore() { + return this; + } + + /** + * Returns metadata mapping for an entity class. + * @param cls entity class + * @return Map + */ + public Map get(Type cls) { + return dataStore.get(cls); + } + + @Override + public void cleanseTestData() { + for (Map objects : dataStore.values()) { + objects.clear(); + } + typeIds.clear(); + } +} 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 new file mode 100644 index 0000000000..45eee754e4 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java @@ -0,0 +1,212 @@ +/* + * 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.datastore.inmemory; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.TransactionException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.coerce.converters.Serde; + +import java.io.IOException; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import javax.persistence.GeneratedValue; + +/** + * HashMapDataStore transaction handler. + */ +public class HashMapStoreTransaction implements DataStoreTransaction { + private final Map, Map> dataStore; + private final List operations; + private final EntityDictionary dictionary; + private final Map, AtomicLong> typeIds; + + public HashMapStoreTransaction(Map, Map> dataStore, + EntityDictionary dictionary, Map, AtomicLong> typeIds) { + this.dataStore = dataStore; + this.dictionary = dictionary; + this.operations = new ArrayList<>(); + this.typeIds = typeIds; + } + + @Override + public void flush(RequestScope requestScope) { + // Do nothing + } + + @Override + public void save(Object object, RequestScope requestScope) { + if (object == null) { + return; + } + String id = dictionary.getId(object); + if (id == null || "null".equals(id) || "0".equals(id)) { + createObject(object, requestScope); + } + id = dictionary.getId(object); + operations.add(new Operation(id, object, EntityDictionary.getType(object), Operation.OpType.UPDATE)); + replicateOperationToParent(object, Operation.OpType.UPDATE); + } + + @Override + public void delete(Object object, RequestScope requestScope) { + if (object == null) { + return; + } + + String id = dictionary.getId(object); + operations.add(new Operation(id, object, EntityDictionary.getType(object), Operation.OpType.DELETE)); + replicateOperationToParent(object, Operation.OpType.DELETE); + } + + @Override + public void commit(RequestScope scope) { + synchronized (dataStore) { + operations.stream() + .filter(op -> op.getInstance() != null) + .forEach(op -> { + Object instance = op.getInstance(); + String id = op.getId(); + Map data = dataStore.get(op.getType()); + if (op.getOpType() == Operation.OpType.DELETE) { + data.remove(id); + } else { + if (op.getOpType() == Operation.OpType.CREATE && data.get(id) != null) { + throw new TransactionException(new IllegalStateException("Duplicate key")); + } + data.put(id, instance); + } + }); + operations.clear(); + } + } + + @Override + public void createObject(Object entity, RequestScope scope) { + Type entityClass = EntityDictionary.getType(entity); + + String idFieldName = dictionary.getIdFieldName(entityClass); + String id; + + if (containsObject(entity)) { + throw new TransactionException(new IllegalStateException("Duplicate key")); + } + + //GeneratedValue means the DB needs to assign the ID. + if (dictionary.getAttributeOrRelationAnnotation(entityClass, GeneratedValue.class, idFieldName) != null) { + // TODO: Id's are not necessarily numeric. + AtomicLong nextId; + synchronized (dataStore) { + nextId = getId(entityClass); + } + id = String.valueOf(nextId.getAndIncrement()); + setId(entity, id); + } else { + id = dictionary.getId(entity); + } + + replicateOperationToParent(entity, Operation.OpType.CREATE); + operations.add(new Operation(id, entity, EntityDictionary.getType(entity), Operation.OpType.CREATE)); + } + + public void setId(Object value, String id) { + dictionary.setValue(value, dictionary.getIdFieldName(EntityDictionary.getType(value)), id); + } + + @Override + public DataStoreIterable getToManyRelation(DataStoreTransaction relationTx, + Object entity, + Relationship relationship, + RequestScope scope) { + return new DataStoreIterableBuilder( + (Iterable) dictionary.getValue(entity, relationship.getName(), scope)).allInMemory().build(); + } + + @Override + public DataStoreIterable loadObjects(EntityProjection projection, + RequestScope scope) { + synchronized (dataStore) { + Map data = dataStore.get(projection.getType()); + return new DataStoreIterableBuilder<>(data.values()).allInMemory().build(); + } + } + + @Override + public Object loadObject(EntityProjection projection, Serializable id, RequestScope scope) { + + EntityDictionary dictionary = scope.getDictionary(); + + synchronized (dataStore) { + Map data = dataStore.get(projection.getType()); + if (data == null) { + return null; + } + Serde serde = dictionary.getSerdeLookup().apply(id.getClass()); + + String idString = (serde == null) ? id.toString() : (String) serde.serialize(id); + return data.get(idString); + } + } + + @Override + public void close() throws IOException { + operations.clear(); + } + + private boolean containsObject(Object obj) { + return containsObject(EntityDictionary.getType(obj), obj); + } + + private boolean containsObject(Type clazz, Object obj) { + return dataStore.get(clazz).containsValue(obj); + } + + @Override + public void cancel(RequestScope scope) { + //nothing to cancel in HashMap store transaction + } + + private void replicateOperationToParent(Object entity, Operation.OpType opType) { + dictionary.getSuperClassEntities(EntityDictionary.getType(entity)).stream() + .forEach(superClass -> { + if (opType.equals(Operation.OpType.CREATE) && containsObject(superClass, entity)) { + throw new TransactionException(new IllegalStateException("Duplicate key in Parent")); + } + String id = dictionary.getId(entity); + operations.add(new Operation(id, entity, superClass, opType)); + }); + } + + /** + * Get shared ID from Parent for inherited classes. + * If not inherited, generate new ID. + * @param entityClass Class Type of Entity + * @return AtomicLong instance for Id generation. + */ + private AtomicLong getId(Type entityClass) { + return dictionary.getSuperClassEntities(entityClass).stream() + .findFirst() + .map(this::getId) + .orElseGet(() -> typeIds.computeIfAbsent(entityClass, + (key) -> { + long maxId = dataStore.get(key).keySet().stream() + .mapToLong(Long::parseLong) + .max() + .orElse(0); + return new AtomicLong(maxId + 1); + } + )); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryDataStore.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryDataStore.java new file mode 100644 index 0000000000..4b46f1dfba --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryDataStore.java @@ -0,0 +1,44 @@ +/* + * 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.datastore.inmemory; + +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityDictionary; + +/** + * Data Store that wraps another store and provides in-memory filtering, soring, and pagination + * when the underlying store cannot perform the equivalent function. + */ +public class InMemoryDataStore implements DataStore { + + private DataStore wrappedStore; + + public InMemoryDataStore(DataStore wrappedStore) { + this.wrappedStore = wrappedStore; + } + + @Override + public void populateEntityDictionary(EntityDictionary dictionary) { + wrappedStore.populateEntityDictionary(dictionary); + } + + @Override + public DataStoreTransaction beginTransaction() { + return new InMemoryStoreTransaction(wrappedStore.beginTransaction()); + } + + @Override + public DataStoreTransaction beginReadTransaction() { + return new InMemoryStoreTransaction(wrappedStore.beginReadTransaction()); + } + + @Override + public String toString() { + return "Wrapped:[" + String.valueOf(wrappedStore) + "]"; + } +} 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 new file mode 100644 index 0000000000..4cf1da1aff --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java @@ -0,0 +1,459 @@ +/* + * 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.datastore.inmemory; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.expression.FilterPredicatePushdownExtractor; +import com.yahoo.elide.core.filter.expression.InMemoryExecutionVerifier; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Pagination; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.core.request.Sorting; +import com.yahoo.elide.core.type.Type; +import org.apache.commons.lang3.tuple.Pair; + +import java.io.IOException; +import java.io.Serializable; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +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; + } + if (a == null) { + return -1; + } + if (b == null) { + return 1; + } + if (a instanceof Comparable) { + return ((Comparable) a).compareTo(b); + } + throw new IllegalStateException("Trying to comparing non-comparable types!"); + }; + + /** + * Fetches data from the store. + */ + @FunctionalInterface + private interface DataFetcher { + DataStoreIterable fetch(Optional filterExpression, + Optional sorting, + Optional pagination, + RequestScope scope); + } + + public InMemoryStoreTransaction(DataStoreTransaction tx) { + this.tx = tx; + } + + @Override + public DataStoreIterable getToManyRelation(DataStoreTransaction relationTx, + Object entity, + Relationship relationship, + RequestScope scope) { + DataFetcher fetcher = (filterExpression, sorting, pagination, requestScope) -> + tx.getToManyRelation(relationTx, entity, relationship.copyOf() + .projection(relationship.getProjection().copyOf() + .filterExpression(filterExpression.orElse(null)) + .sorting(sorting.orElse(null)) + .pagination(pagination.orElse(null)) + .build() + ).build(), requestScope); + + + /* + * 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(), filterInMemory, scope); + } + + @Override + public Object loadObject(EntityProjection projection, + Serializable id, + RequestScope scope) { + if (projection.getFilterExpression() == null) { + return tx.loadObject(projection, id, scope); + } + + return DataStoreTransaction.super.loadObject(projection, id, scope); + } + + @Override + public DataStoreIterable loadObjects(EntityProjection projection, + RequestScope scope) { + + DataFetcher fetcher = (filterExpression, sorting, pagination, requestScope) -> + tx.loadObjects(projection.copyOf() + .filterExpression(filterExpression.orElse(null)) + .pagination(pagination.orElse(null)) + .sorting(sorting.orElse(null)) + .build(), requestScope); + + return fetchData(fetcher, projection, false, scope); + } + + @Override + public void save(Object entity, RequestScope scope) { + tx.save(entity, scope); + } + + @Override + public void delete(Object entity, RequestScope scope) { + tx.delete(entity, scope); + } + + @Override + public void preCommit(RequestScope scope) { + tx.preCommit(scope); + } + + @Override + public T createNewObject(Type entityClass, RequestScope scope) { + return tx.createNewObject(entityClass, scope); + } + + @Override + public R getToOneRelation( + DataStoreTransaction relationTx, + T entity, Relationship relationship, + RequestScope scope + ) { + return tx.getToOneRelation(relationTx, entity, relationship, scope); + } + + @Override + public void close() throws IOException { + tx.close(); + } + + @Override + public void updateToManyRelation(DataStoreTransaction relationTx, + T entity, + String relationName, + Set newRelationships, + Set deletedRelationships, + RequestScope scope) { + tx.updateToManyRelation(relationTx, entity, relationName, newRelationships, deletedRelationships, scope); + } + + @Override + public void updateToOneRelation(DataStoreTransaction relationTx, + T entity, + String relationName, + R relationshipValue, + RequestScope scope) { + tx.updateToOneRelation(relationTx, entity, relationName, relationshipValue, scope); + } + + @Override + public Object getAttribute(Object entity, Attribute attribute, RequestScope scope) { + return tx.getAttribute(entity, attribute, scope); + } + + @Override + public void setAttribute(Object entity, Attribute attribute, RequestScope scope) { + tx.setAttribute(entity, attribute, scope); + } + + @Override + public void flush(RequestScope scope) { + tx.flush(scope); + } + + @Override + public void commit(RequestScope scope) { + tx.commit(scope); + } + + @Override + public void createObject(Object entity, RequestScope scope) { + tx.createObject(entity, scope); + } + + private DataStoreIterable filterLoadedData(DataStoreIterable loadedRecords, + Optional filterExpression, + RequestScope scope) { + + if (! filterExpression.isPresent()) { + return loadedRecords; + } + + return new DataStoreIterable<>() { + @Override + public Iterable getWrappedIterable() { + return loadedRecords; + } + + @Override + public Iterator iterator() { + return new FilteredIterator<>(filterExpression.get(), scope, loadedRecords.iterator()); + } + + @Override + public boolean needsInMemoryFilter() { + return true; + } + + @Override + public boolean needsInMemorySort() { + return true; + } + + @Override + public boolean needsInMemoryPagination() { + return true; + } + }; + } + + private DataStoreIterable fetchData( + DataFetcher fetcher, + EntityProjection projection, + boolean filterInMemory, + RequestScope scope + ) { + Optional filterExpression = Optional.ofNullable(projection.getFilterExpression()); + + Pair, Optional> expressionSplit = splitFilterExpression( + scope, projection, filterInMemory); + + Optional dataStoreFilter = expressionSplit.getLeft(); + Optional inMemoryFilter = expressionSplit.getRight(); + + Optional dataStoreSorting = getDataStoreSorting(scope, projection, filterInMemory); + + boolean sortingInMemory = dataStoreSorting.isEmpty() && projection.getSorting() != null; + + Optional dataStorePagination = inMemoryFilter.isPresent() || sortingInMemory + ? Optional.empty() : Optional.ofNullable(projection.getPagination()); + + DataStoreIterable loadedRecords = + fetcher.fetch(dataStoreFilter, dataStoreSorting, dataStorePagination, scope); + + if (loadedRecords == null) { + return new DataStoreIterableBuilder().build(); + } + + if (inMemoryFilter.isPresent() || (loadedRecords.needsInMemoryFilter() + && projection.getFilterExpression() != null)) { + loadedRecords = filterLoadedData(loadedRecords, filterExpression, scope); + } + + return sortAndPaginateLoadedData( + loadedRecords, + sortingInMemory, + projection.getSorting(), + projection.getPagination(), + scope); + } + + private DataStoreIterable sortAndPaginateLoadedData( + DataStoreIterable loadedRecords, + boolean sortingInMemory, + Sorting sorting, + Pagination pagination, + RequestScope scope + ) { + + Map sortRules = sorting == null ? new HashMap<>() : sorting.getSortingPaths(); + + boolean mustSortInMemory = ! sortRules.isEmpty() + && (sortingInMemory || loadedRecords.needsInMemorySort()); + + boolean mustPaginateInMemory = pagination != null + && (mustSortInMemory || loadedRecords.needsInMemoryPagination()); + + //Try to skip the data copy if possible + if (! mustSortInMemory && ! mustPaginateInMemory) { + return loadedRecords; + } + + //We need an in memory copy to sort or paginate. + List results = StreamSupport.stream(loadedRecords.spliterator(), false).collect(Collectors.toList()); + + if (! sortRules.isEmpty()) { + results = sortInMemory(results, sortRules, scope); + } + + if (pagination != null) { + results = paginateInMemory(results, pagination); + } + + return new DataStoreIterableBuilder(results).build(); + } + + private List paginateInMemory(List records, Pagination pagination) { + int offset = pagination.getOffset(); + int limit = pagination.getLimit(); + if (offset < 0 || offset >= records.size()) { + return Collections.emptyList(); + } + + int endIdx = offset + limit; + if (endIdx > records.size()) { + endIdx = records.size(); + } + + if (pagination.returnPageTotals()) { + pagination.setPageTotals((long) records.size()); + } + return records.subList(offset, endIdx); + } + + private List sortInMemory(List records, + Map sortRules, + RequestScope scope) { + //Build a comparator that handles multiple comparison rules. + Comparator noSort = (left, right) -> 0; + + Comparator comp = sortRules.entrySet().stream() + .map(entry -> getComparator(entry.getKey(), entry.getValue(), scope)) + .reduce(noSort, (comparator1, comparator2) -> (left, right) -> { + int comparison = comparator1.compare(left, right); + if (comparison == 0) { + return comparator2.compare(left, right); + } + return comparison; + }); + + records.sort(comp); + return records; + } + + private Comparator getComparator(Path path, Sorting.SortOrder order, RequestScope requestScope) { + return (left, right) -> { + Object leftCompare = left; + Object rightCompare = right; + + // Drill down into path to find value for comparison + for (Path.PathElement pathElement : path.getPathElements()) { + leftCompare = (leftCompare == null ? null + : PersistentResource.getValue(leftCompare, pathElement.getFieldName(), requestScope)); + rightCompare = (rightCompare == null ? null + : PersistentResource.getValue(rightCompare, pathElement.getFieldName(), requestScope)); + } + + if (order == Sorting.SortOrder.asc) { + return NULL_SAFE_COMPARE.compare(leftCompare, rightCompare); + } + return NULL_SAFE_COMPARE.compare(rightCompare, leftCompare); + }; + } + + /** + * Returns the sorting (if any) that should be pushed to the datastore. + * @param scope The request context + * @param projection The projection being loaded. + * @param filterInMemory Whether or not the transaction requires in memory filtering. + * @return An optional sorting. + */ + private Optional getDataStoreSorting( + RequestScope scope, + EntityProjection projection, + boolean filterInMemory + ) { + Sorting sorting = projection.getSorting(); + if (filterInMemory) { + return Optional.empty(); + } + Map sortRules = sorting == null ? new HashMap<>() : sorting.getSortingPaths(); + + boolean sortingOnComputedAttribute = false; + for (Path path: sortRules.keySet()) { + if (path.isComputed(scope.getDictionary())) { + Type pathType = path.getPathElements().get(0).getType(); + if (projection.getType().equals(pathType)) { + sortingOnComputedAttribute = true; + break; + } + } + } + if (sortingOnComputedAttribute) { + return Optional.empty(); + } else { + return Optional.ofNullable(sorting); + } + } + + /** + * Splits a filter expression into two components. They are: + * - a component that should be pushed down to the data store + * - a component that should be executed in memory + * @param scope The request context + * @param projection The projection being loaded. + * @param filterInMemory Whether or not the transaction requires in memory filtering. + * @return A pair of filter expressions (data store expression, in memory expression) + */ + private Pair, Optional> splitFilterExpression( + RequestScope scope, + EntityProjection projection, + boolean filterInMemory + ) { + + Optional filterExpression = Optional.ofNullable(projection.getFilterExpression()); + Optional inStoreFilterExpression = filterExpression; + Optional inMemoryFilterExpression = Optional.empty(); + + boolean transactionNeedsInMemoryFiltering = filterInMemory; + + if (filterExpression.isPresent()) { + if (transactionNeedsInMemoryFiltering) { + inStoreFilterExpression = Optional.empty(); + } else { + inStoreFilterExpression = Optional.ofNullable( + FilterPredicatePushdownExtractor.extractPushDownPredicate(scope.getDictionary(), + filterExpression.get())); + } + + boolean expressionNeedsInMemoryFiltering = InMemoryExecutionVerifier.shouldExecuteInMemory( + scope.getDictionary(), filterExpression.get()); + + if (transactionNeedsInMemoryFiltering || expressionNeedsInMemoryFiltering) { + inMemoryFilterExpression = filterExpression; + } + } + + return Pair.of(inStoreFilterExpression, inMemoryFilterExpression); + } + + @Override + public void cancel(RequestScope scope) { + tx.cancel(scope); + } + + @Override + public T getProperty(String propertyName) { + return tx.getProperty(propertyName); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/Operation.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/Operation.java new file mode 100644 index 0000000000..fc7fc1043e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/Operation.java @@ -0,0 +1,24 @@ +/* + * 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.datastore.inmemory; + +import com.yahoo.elide.core.type.Type; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +class Operation { + enum OpType { + CREATE, + DELETE, + UPDATE + }; + + @Getter private final String id; + @Getter private final Object instance; + @Getter private final Type type; + @Getter private final OpType opType; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/test/DataStoreTestHarness.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/test/DataStoreTestHarness.java new file mode 100644 index 0000000000..e66d6b8989 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/test/DataStoreTestHarness.java @@ -0,0 +1,17 @@ +/* + * 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.datastore.test; + +import com.yahoo.elide.core.datastore.DataStore; + +/** + * Any data store that wants IT tests to run against it needs to provide an implementation of this harness. + */ +public interface DataStoreTestHarness { + public DataStore getDataStore(); + public void cleanseTestData(); +} 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 new file mode 100644 index 0000000000..dd9781ef8a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapper.java @@ -0,0 +1,121 @@ +/* + * 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.datastore.wrapped; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Relationship; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.io.IOException; +import java.io.Serializable; +import java.util.Set; + +/** + * Delegates all calls to a wrapped transaction. + */ +@Data +@AllArgsConstructor +public abstract class TransactionWrapper implements DataStoreTransaction { + protected DataStoreTransaction tx; + + @Override + public void preCommit(RequestScope scope) { + tx.preCommit(scope); + } + + @Override + public T loadObject(EntityProjection projection, Serializable id, + RequestScope scope) { + return tx.loadObject(projection, id, scope); + } + + @Override + public DataStoreIterable getToManyRelation(DataStoreTransaction relationTx, T entity, + Relationship relationship, RequestScope scope) { + return tx.getToManyRelation(relationTx, entity, relationship, scope); + } + + @Override + public R getToOneRelation(DataStoreTransaction relationTx, T entity, + Relationship relationship, RequestScope scope) { + return tx.getToOneRelation(relationTx, entity, relationship, scope); + } + + @Override + public void updateToManyRelation(DataStoreTransaction relationTx, T entity, String relationName, + Set newRelationships, Set deletedRelationships, + RequestScope scope) { + tx.updateToManyRelation(relationTx, entity, relationName, newRelationships, deletedRelationships, scope); + + } + + @Override + public void updateToOneRelation(DataStoreTransaction relationTx, T entity, + String relationName, R relationshipValue, RequestScope scope) { + tx.updateToOneRelation(relationTx, entity, relationName, relationshipValue, scope); + } + + @Override + public R getAttribute(T entity, Attribute attribute, RequestScope scope) { + return tx.getAttribute(entity, attribute, scope); + } + + @Override + public void setAttribute(T entity, Attribute attribute, RequestScope scope) { + tx.setAttribute(entity, attribute, scope); + } + + @Override + public void save(T o, RequestScope requestScope) { + tx.save(o, requestScope); + } + + @Override + public void delete(T o, RequestScope requestScope) { + tx.delete(o, requestScope); + } + + @Override + public void flush(RequestScope requestScope) { + tx.flush(requestScope); + } + + @Override + public void commit(RequestScope requestScope) { + tx.commit(requestScope); + } + + @Override + public void createObject(Object o, RequestScope requestScope) { + tx.createObject(o, requestScope); + } + + @Override + public DataStoreIterable loadObjects(EntityProjection projection, RequestScope scope) { + return tx.loadObjects(projection, scope); + } + + @Override + public void close() throws IOException { + tx.close(); + } + + @Override + public void cancel(RequestScope scope) { + tx.cancel(scope); + } + + @Override + public T getProperty(String propertyName) { + return tx.getProperty(propertyName); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/ArgumentType.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/ArgumentType.java new file mode 100644 index 0000000000..7024a6a66d --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/ArgumentType.java @@ -0,0 +1,31 @@ +/* + * 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.dictionary; + +import com.yahoo.elide.core.type.Type; +import lombok.Builder; +import lombok.Value; + +/** + * Argument Type wraps an argument to the type of value it accepts. + */ +@Value +public class ArgumentType { + private String name; + private Type type; + private Object defaultValue; + + public ArgumentType(String name, Type type) { + this(name, type, null); + } + + @Builder + public ArgumentType(String name, Type type, Object defaultValue) { + this.name = name; + this.type = type; + this.defaultValue = defaultValue; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityBinding.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityBinding.java new file mode 100644 index 0000000000..3aaae7a159 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityBinding.java @@ -0,0 +1,757 @@ +/* + * 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.dictionary; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static com.yahoo.elide.core.dictionary.EntityDictionary.REGULAR_ID_NAME; +import static com.yahoo.elide.core.type.ClassType.OBJ_METHODS; +import com.yahoo.elide.annotation.ComputedAttribute; +import com.yahoo.elide.annotation.ComputedRelationship; +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.annotation.LifeCycleHookBinding.Operation; +import com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase; +import com.yahoo.elide.annotation.ToMany; +import com.yahoo.elide.annotation.ToOne; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.exceptions.DuplicateMappingException; +import com.yahoo.elide.core.lifecycle.LifeCycleHook; +import com.yahoo.elide.core.type.AccessibleObject; +import com.yahoo.elide.core.type.Field; +import com.yahoo.elide.core.type.Member; +import com.yahoo.elide.core.type.Method; +import com.yahoo.elide.core.type.Type; +import org.apache.commons.collections4.MultiValuedMap; +import org.apache.commons.collections4.multimap.HashSetValuedHashMap; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Triple; +import lombok.Getter; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +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; +import javax.persistence.EmbeddedId; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.ManyToMany; +import javax.persistence.ManyToOne; +import javax.persistence.MapsId; +import javax.persistence.OneToMany; +import javax.persistence.OneToOne; +import javax.persistence.Transient; + +/** + * Entity Dictionary maps JSON API Entity beans to/from Entity type names. + * + * @see com.yahoo.elide.annotation.Include#name + */ +public class EntityBinding { + public static final List> ID_ANNOTATIONS = List.of(Id.class, EmbeddedId.class); + + private static final List> RELATIONSHIP_TYPES = + Arrays.asList(ManyToMany.class, ManyToOne.class, OneToMany.class, OneToOne.class, + ToOne.class, ToMany.class); + + @Getter + private final boolean isElideModel; + @Getter + public final Type entityClass; + @Getter + public final String jsonApiType; + @Getter + public boolean idGenerated; + @Getter + private AccessibleObject idField; + @Getter + private String idFieldName; + @Getter + private Type idType; + @Getter + private AccessType accessType; + + @Getter + private final boolean injected; + + private Injector injector; + + @Getter + private String apiVersion; + + public final EntityPermissions entityPermissions; + public final List apiAttributes; + public final List apiRelationships; + public final List> inheritedTypes; + public final ConcurrentLinkedDeque attributesDeque = new ConcurrentLinkedDeque<>(); + public final ConcurrentLinkedDeque relationshipsDeque = new ConcurrentLinkedDeque<>(); + + public final ConcurrentHashMap relationshipTypes = new ConcurrentHashMap<>(); + public final ConcurrentHashMap relationshipToInverse = new ConcurrentHashMap<>(); + public final ConcurrentHashMap relationshipToCascadeTypes = new ConcurrentHashMap<>(); + public final ConcurrentHashMap fieldsToValues = new ConcurrentHashMap<>(); + 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 entityArguments = 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<>()); + + /* empty binding constructor */ + private EntityBinding() { + isElideModel = false; + injected = false; + jsonApiType = null; + apiVersion = NO_VERSION; + apiAttributes = new ArrayList<>(); + apiRelationships = new ArrayList<>(); + inheritedTypes = new ArrayList<>(); + idField = null; + idType = null; + entityClass = null; + entityPermissions = EntityPermissions.EMPTY_PERMISSIONS; + idGenerated = false; + injector = null; + } + + /** + * Constructor + * + * @param injector Instantiates and injects new entities + * @param cls Entity class + * @param type Declared Elide type name + */ + public EntityBinding(Injector injector, + Type cls, + String type) { + this(injector, cls, type, NO_VERSION, unused -> false); + } + + /** + * Constructor + * + * @param injector Instantiates and injects new entities. + * @param cls Entity class + * @param type Declared Elide type name + * @param apiVersion API version + * @param isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. + */ + public EntityBinding(Injector injector, + Type cls, + String type, + String apiVersion, + Predicate isFieldHidden) { + this(injector, cls, type, apiVersion, true, isFieldHidden); + } + + /** + * Constructor + * + * @param injector Instantiates and injects new entities. + * @param cls Entity class + * @param type Declared Elide type name + * @param apiVersion API version + * @param isElideModel Whether or not this type is an Elide model or not. + * @param isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. + */ + public EntityBinding(Injector injector, + Type cls, + String type, + String apiVersion, + boolean isElideModel, + Predicate isFieldHidden) { + this.isElideModel = isElideModel; + this.injector = injector; + entityClass = cls; + jsonApiType = type; + this.apiVersion = apiVersion; + inheritedTypes = getInheritedTypes(cls); + + // Map id's, attributes, and relationships + List fieldOrMethodList = getAllFields(); + injected = shouldInject(); + + if (fieldOrMethodList.stream().anyMatch(EntityBinding::isIdField)) { + accessType = AccessType.FIELD; + + /* Add all public methods that are computed OR life cycle hooks */ + fieldOrMethodList.addAll( + getInstanceMembers(cls.getMethods(), + (method) -> method.isAnnotationPresent(LifeCycleHookBinding.class) + || method.isAnnotationPresent(ComputedAttribute.class) + || method.isAnnotationPresent(ComputedRelationship.class) + ) + ); + + //Elide needs to manipulate private fields that are exposed. + fieldOrMethodList.forEach(field -> field.setAccessible(true)); + } else { + /* Preserve the behavior of Elide 4.2.6 and earlier */ + accessType = AccessType.PROPERTY; + + fieldOrMethodList.clear(); + + /* Add all public fields */ + fieldOrMethodList.addAll(getInstanceMembers(cls.getFields())); + + /* Add all public methods */ + fieldOrMethodList.addAll(getInstanceMembers(cls.getMethods())); + } + + bindEntityFields(cls, type, fieldOrMethodList, isFieldHidden); + bindTriggerIfPresent(); + + apiAttributes = dequeToList(attributesDeque); + apiRelationships = dequeToList(relationshipsDeque); + entityPermissions = new EntityPermissions(cls, fieldOrMethodList); + } + + /** + * Filters a list of class Members to instance methods & fields. + * + * @param objects + * @param + * @return A list of the filtered members + */ + private List getInstanceMembers(T[] objects) { + return getInstanceMembers(objects, o -> true); + } + + /** + * Filters a list of class Members to instance methods & fields. + * + * @param objects The list of Members to filter + * @param Concrete Member Type + * @param filteredBy An additional filter predicate to apply + * @return A list of the filtered members + */ + private List getInstanceMembers(T[] objects, Predicate filteredBy) { + return Arrays.stream(objects) + .filter(o -> !Modifier.isStatic(o.getModifiers())) + .filter(filteredBy) + .collect(Collectors.toList()); + } + + /** + * Get all fields of the entity class, including fields of superclasses (excluding Object). + * @return All fields of the EntityBindings entity class and all superclasses (excluding Object) + */ + public List getAllFields() { + List fields = new ArrayList<>(); + + fields.addAll(getInstanceMembers(entityClass.getDeclaredFields(), (field) -> !field.isSynthetic())); + for (Type type : inheritedTypes) { + fields.addAll(getInstanceMembers(type.getDeclaredFields(), (field) -> !field.isSynthetic())); + } + + return fields; + } + + public List getAllMethods() { + List methods = new ArrayList<>(); + + methods.addAll(getInstanceMembers(entityClass.getDeclaredMethods(), (method) -> !method.isSynthetic())); + for (Type 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 isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. + */ + private void bindEntityFields(Type cls, String type, + Collection fieldOrMethodList, + Predicate isFieldHidden) { + for (AccessibleObject fieldOrMethod : fieldOrMethodList) { + bindTriggerIfPresent(fieldOrMethod); + + if (isIdField(fieldOrMethod)) { + bindEntityId(cls, type, fieldOrMethod); + } else if (fieldOrMethod.isAnnotationPresent(Transient.class) + && !fieldOrMethod.isAnnotationPresent(ComputedAttribute.class) + && !fieldOrMethod.isAnnotationPresent(ComputedRelationship.class)) { + continue; // Transient. Don't serialize + } else if (!fieldOrMethod.isAnnotationPresent(Exclude.class)) { + if (fieldOrMethod instanceof Field && Modifier.isTransient(((Field) fieldOrMethod).getModifiers())) { + continue; // Transient. Don't serialize + } + if (fieldOrMethod instanceof Method && Modifier.isTransient(((Method) fieldOrMethod).getModifiers())) { + continue; // Transient. Don't serialize + } + if (fieldOrMethod instanceof Field + && !fieldOrMethod.isAnnotationPresent(Column.class) + && Modifier.isStatic(((Field) fieldOrMethod).getModifiers())) { + continue; // Field must have Column annotation? + } + bindAttrOrRelation( + fieldOrMethod, + isFieldHidden.test(fieldOrMethod)); + } + } + } + + /** + * Bind an id field to an entity. + * + * @param cls Class type to bind fields + * @param type JSON API type identifier + * @param fieldOrMethod Field or method to bind + */ + private void bindEntityId(Type cls, String type, AccessibleObject fieldOrMethod) { + String fieldName = getFieldName(fieldOrMethod); + Type fieldType = getFieldType(cls, fieldOrMethod); + + //Add id field to type map for the entity + fieldsToTypes.put(fieldName, fieldType); + + //Set id field, type, and name + idField = fieldOrMethod; + idType = fieldType; + idFieldName = fieldName; + + fieldsToValues.put(fieldName, fieldOrMethod); + + if (idField != null && !fieldOrMethod.equals(idField)) { + throw new DuplicateMappingException(type + " " + cls.getName() + ":" + fieldName); + } + if (fieldOrMethod.isAnnotationPresent(GeneratedValue.class)) { + idGenerated = true; + } + } + + /** + * Convert a deque to a list. + * + * @param deque Deque to convert + * @return Deque as a list + */ + private static List dequeToList(final Deque deque) { + ArrayList result = new ArrayList<>(); + deque.stream().forEachOrdered(result::add); + result.sort(String.CASE_INSENSITIVE_ORDER); + return Collections.unmodifiableList(result); + } + + /** + * 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, boolean isHidden) { + boolean isRelation = RELATIONSHIP_TYPES.stream().anyMatch(fieldOrMethod::isAnnotationPresent); + + String fieldName = getFieldName(fieldOrMethod); + Type fieldType = getFieldType(entityClass, fieldOrMethod); + + if (fieldName == null || REGULAR_ID_NAME.equals(fieldName) || "class".equals(fieldName) + || OBJ_METHODS.contains(fieldOrMethod)) { + return; // Reserved + } + + if (fieldOrMethod instanceof Method) { + Method method = (Method) fieldOrMethod; + requestScopeableMethods.put(method, isRequestScopeableMethod(method)); + } + + if (isRelation) { + bindRelation(fieldOrMethod, fieldName, fieldType, isHidden); + } else { + bindAttr(fieldOrMethod, fieldName, fieldType, isHidden); + } + } + + /** + * 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, Type fieldType, boolean isHidden) { + boolean manyToMany = fieldOrMethod.isAnnotationPresent(ManyToMany.class); + boolean manyToOne = fieldOrMethod.isAnnotationPresent(ManyToOne.class); + boolean oneToMany = fieldOrMethod.isAnnotationPresent(OneToMany.class); + boolean oneToOne = fieldOrMethod.isAnnotationPresent(OneToOne.class); + boolean toOne = fieldOrMethod.isAnnotationPresent(ToOne.class); + boolean toMany = fieldOrMethod.isAnnotationPresent(ToMany.class); + boolean computedRelationship = fieldOrMethod.isAnnotationPresent(ComputedRelationship.class); + + if (fieldOrMethod.isAnnotationPresent(MapsId.class)) { + idGenerated = true; + } + + RelationshipType type; + String mappedBy = ""; + CascadeType[] cascadeTypes = new CascadeType[0]; + if (oneToMany) { + type = computedRelationship ? RelationshipType.COMPUTED_ONE_TO_MANY : RelationshipType.ONE_TO_MANY; + mappedBy = fieldOrMethod.getAnnotation(OneToMany.class).mappedBy(); + cascadeTypes = fieldOrMethod.getAnnotation(OneToMany.class).cascade(); + } else if (oneToOne) { + type = computedRelationship ? RelationshipType.COMPUTED_ONE_TO_ONE : RelationshipType.ONE_TO_ONE; + mappedBy = fieldOrMethod.getAnnotation(OneToOne.class).mappedBy(); + cascadeTypes = fieldOrMethod.getAnnotation(OneToOne.class).cascade(); + } else if (manyToMany) { + type = computedRelationship ? RelationshipType.COMPUTED_MANY_TO_MANY : RelationshipType.MANY_TO_MANY; + mappedBy = fieldOrMethod.getAnnotation(ManyToMany.class).mappedBy(); + cascadeTypes = fieldOrMethod.getAnnotation(ManyToMany.class).cascade(); + } else if (manyToOne) { + type = computedRelationship ? RelationshipType.COMPUTED_MANY_TO_ONE : RelationshipType.MANY_TO_ONE; + cascadeTypes = fieldOrMethod.getAnnotation(ManyToOne.class).cascade(); + } else if (toOne) { + type = RelationshipType.COMPUTED_ONE_TO_ONE; + } else if (toMany) { + type = RelationshipType.COMPUTED_ONE_TO_MANY; + } else { + type = computedRelationship ? RelationshipType.COMPUTED_NONE : RelationshipType.NONE; + } + relationshipTypes.put(fieldName, type); + relationshipToInverse.put(fieldName, mappedBy); + relationshipToCascadeTypes.put(fieldName, cascadeTypes); + + if (!isHidden) { + relationshipsDeque.push(fieldName); + } + fieldsToValues.put(fieldName, fieldOrMethod); + fieldsToTypes.put(fieldName, fieldType); + } + + /** + * 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, Type fieldType, boolean isHidden) { + if (!isHidden) { + attributesDeque.push(fieldName); + } + fieldsToValues.put(fieldName, fieldOrMethod); + fieldsToTypes.put(fieldName, fieldType); + } + + /** + * Returns name of field whether public member or method. + * + * @param fieldOrMethod field or method + * @return field or method name + */ + public static String getFieldName(AccessibleObject fieldOrMethod) { + if (fieldOrMethod instanceof Field) { + return ((Field) fieldOrMethod).getName(); + } + Method method = (Method) fieldOrMethod; + String name = method.getName(); + boolean hasValidParameterCount = method.getParameterCount() == 0 || isRequestScopeableMethod(method); + + if (name.startsWith("get") && hasValidParameterCount) { + return StringUtils.uncapitalize(name.substring("get".length())); + } + if (name.startsWith("is") && hasValidParameterCount) { + return StringUtils.uncapitalize(name.substring("is".length())); + } + return null; + } + + /** + * Check whether or not method expects a RequestScope. + * + * @param method Method to check + * @return True if accepts a RequestScope, false otherwise + */ + public static boolean isRequestScopeableMethod(Method method) { + return isComputedMethod(method) && method.getParameterCount() == 1 + && com.yahoo.elide.core.security.RequestScope.class.isAssignableFrom(method.getParameterTypes()[0]); + } + + /** + * Check whether or not the provided method described a computed attribute or relationship. + * + * @param method Method to check + * @return True if method is a computed type, false otherwise + */ + public static boolean isComputedMethod(Method method) { + return Stream.of(method.getAnnotations()) + .map(Annotation::annotationType) + .anyMatch(c -> ComputedAttribute.class == c || ComputedRelationship.class == c); + } + + /** + * Returns type of field whether member or method. + * + * @param parentClass The class which owns the given field or method + * @param fieldOrMethod field or method + * @return field type + */ + public static Type getFieldType(Type parentClass, + AccessibleObject fieldOrMethod) { + return getFieldType(parentClass, fieldOrMethod, Optional.empty()); + } + + /** + * Returns type of field whether member or method. + * + * @param parentClass The class which owns the given field or method + * @param fieldOrMethod field or method + * @param index Optional parameter index for parameterized types that take one or more parameters. If + * an index is provided, the type returned is the parameter type. Otherwise it is the + * parameterized type. + * @return field type + */ + public static Type getFieldType(Type parentClass, + AccessibleObject fieldOrMethod, + Optional index) { + if (fieldOrMethod instanceof Field) { + return ((Field) fieldOrMethod).getParameterizedType(parentClass, index); + } + return ((Method) fieldOrMethod).getParameterizedReturnType(parentClass, index); + } + + 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(Operation operation, + TransactionPhase phase, + String fieldOrMethodName, + LifeCycleHook hook) { + Triple key = + Triple.of(fieldOrMethodName, operation, phase); + + fieldTriggers.put(key, hook); + } + + private void bindTrigger(LifeCycleHookBinding binding, + String fieldOrMethodName) { + LifeCycleHook hook = injector.instantiate(binding.hook()); + injector.inject(hook); + bindTrigger(binding.operation(), binding.phase(), fieldOrMethodName, hook); + } + + public void bindTrigger(Operation operation, + 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; + } + + LifeCycleHook hook = injector.instantiate(binding.hook()); + injector.inject(hook); + bindTrigger(binding.operation(), binding.phase(), hook); + } + + public Collection getTriggers(Operation op, + 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(Operation op, + TransactionPhase phase) { + + Pair key = + Pair.of(op, phase); + Collection bindings = classTriggers.get(key); + return (bindings == null ? Collections.emptyList() : bindings); + } + + /** + * Cache placeholder for no annotation. + */ + private static final Annotation NO_ANNOTATION = () -> null; + + /** + * Return annotation from class, parents or package. + * + * @param annotationClass the annotation class + * @param annotation type + * @return the annotation + */ + public A getAnnotation(Class annotationClass) { + Annotation annotation = annotations.computeIfAbsent(annotationClass, cls -> Optional.ofNullable( + EntityDictionary.getFirstAnnotation(entityClass, Collections.singletonList(annotationClass))) + .orElse(NO_ANNOTATION)); + return annotation == NO_ANNOTATION ? null : annotationClass.cast(annotation); + } + + /** + * Return annotation for provided method. + * + * @param annotationClass the annotation class + * @param method the method + * @param annotation type + * @return the annotation + */ + public A getMethodAnnotation(Class annotationClass, String method) { + Annotation annotation = annotations.computeIfAbsent(Pair.of(annotationClass, method), key -> { + try { + return Optional.ofNullable((Annotation) entityClass.getMethod(method).getAnnotation(annotationClass)) + .orElse(NO_ANNOTATION); + } catch (NoSuchMethodException | SecurityException e) { + throw new IllegalStateException(e); + } + }); + 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(Type entityCls) { + ArrayList> results = new ArrayList<>(); + + for (Type cls = entityCls.getSuperclass(); cls != null && cls.hasSuperType(); cls = cls.getSuperclass()) { + results.add(cls); + } + + 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; + } + + /** + * Add argument to this Entity. + * @param argument Argument Type for the attribute + */ + public void addArgumentToEntity(ArgumentType argument) { + if (argument != null) { + //Replace any argument names with new value + entityArguments.put(argument.getName(), argument); + } + } + + /** + * Returns all the bound model attribute types. + * @return model attribute types. + */ + public Set> getAttributes() { + return apiAttributes + .stream() + .map((attributeName) -> fieldsToTypes.get(attributeName)) + .collect(Collectors.toSet()); + } + + /** + * Returns a list of fields filtered by a given predicate. + * @param filter The filter predicate. + * @return All fields that satisfy the predicate. + */ + public Set getAllFields(Predicate filter) { + return fieldsToValues.values().stream().filter(filter).collect(Collectors.toSet()); + } + + /** + * Returns the Collection of all attributes of an Entity. + * @return A Set of ArgumentType for the given entity. + */ + public Set getEntityArguments() { + return new HashSet<>(entityArguments.values()); + } + + public static boolean isIdField(AccessibleObject field) { + return (field.isAnnotationPresent(Id.class) || field.isAnnotationPresent(EmbeddedId.class)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java new file mode 100644 index 0000000000..a993929fff --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java @@ -0,0 +1,2245 @@ +/* + * Copyright 2018, Yahoo Inc. + * Copyright 2018, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.dictionary; + +import static com.yahoo.elide.core.dictionary.EntityBinding.EMPTY_BINDING; +import static com.yahoo.elide.core.security.checks.prefab.Role.ALL_ROLE; +import static com.yahoo.elide.core.security.checks.prefab.Role.NONE_ROLE; +import static com.yahoo.elide.core.type.ClassType.COLLECTION_TYPE; +import static com.yahoo.elide.core.type.ClassType.MAP_TYPE; +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.Operation; +import com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase; +import com.yahoo.elide.annotation.NonTransferable; +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.OnUpdatePostCommit; +import com.yahoo.elide.annotation.OnUpdatePreCommit; +import com.yahoo.elide.annotation.OnUpdatePreSecurity; +import com.yahoo.elide.annotation.SecurityCheck; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +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.core.lifecycle.LifeCycleHook; +import com.yahoo.elide.core.security.PermissionExecutor; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.security.checks.UserCheck; +import com.yahoo.elide.core.security.checks.prefab.Collections.AppendOnly; +import com.yahoo.elide.core.security.checks.prefab.Collections.RemoveOnly; +import com.yahoo.elide.core.security.checks.prefab.Role; +import com.yahoo.elide.core.type.AccessibleObject; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Dynamic; +import com.yahoo.elide.core.type.Field; +import com.yahoo.elide.core.type.Method; +import com.yahoo.elide.core.type.Package; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.core.utils.coerce.converters.Serde; +import com.google.common.base.Preconditions; +import com.google.common.base.Throwables; +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.collections4.CollectionUtils; +import org.apache.commons.lang3.ClassUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import lombok.Builder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.lang.annotation.Annotation; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; +import java.util.function.Predicate; +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; + +/** + * Entity Dictionary maps JSON API Entity beans to/from Entity type names. + * + * @see Include#name + */ +@Slf4j +@SuppressWarnings("static-method") +public class EntityDictionary { + + public static final String ELIDE_PACKAGE_PREFIX = "com.yahoo.elide"; + public static final String NO_VERSION = ""; + + public static final Injector DEFAULT_INJECTOR = (noop) -> { + //NOOP + }; + private static final Map, Type> TYPE_MAP = new ConcurrentHashMap<>(); + + protected final ConcurrentHashMap, Type> bindJsonApiToEntity = new ConcurrentHashMap<>(); + protected final ConcurrentHashMap, EntityBinding> entityBindings = new ConcurrentHashMap<>(); + + @Getter + protected final ConcurrentHashMap, Function> entityPermissionExecutor = + new ConcurrentHashMap<>(); + protected final CopyOnWriteArrayList> bindEntityRoots = new CopyOnWriteArrayList<>(); + protected final ConcurrentHashMap, List>> subclassingEntities = new ConcurrentHashMap<>(); + protected final BiMap> checkNames; + protected final Map, Check> checkInstances; + protected final Map roleChecks; + + @Getter + protected final Set apiVersions; + + @Getter + protected final Injector injector; + + @Getter + protected final ClassScanner scanner; + + @Getter + protected final Function serdeLookup ; + + @Getter + private final Set> entitiesToExclude; + + public static final String REGULAR_ID_NAME = "id"; + private static final ConcurrentHashMap SIMPLE_NAMES = new ConcurrentHashMap<>(); + private static final String ALL_FIELDS = "*"; + + @Builder + public EntityDictionary(Map> checks, + Map roleChecks, + Injector injector, + Function serdeLookup, + Set> entitiesToExclude, + ClassScanner scanner) { + this.scanner = scanner; + this.serdeLookup = serdeLookup; + this.checkNames = Maps.synchronizedBiMap(HashBiMap.create(checks)); + this.checkInstances = new ConcurrentHashMap<>(); + this.roleChecks = roleChecks == null ? new HashMap<>() : new HashMap<>(roleChecks); + this.apiVersions = new HashSet<>(); + initializeChecks(); + this.injector = injector; + this.entitiesToExclude = new HashSet<>(entitiesToExclude); + + //Hydrate check instances at boot. + checkNames.keySet().forEach(checkName -> { + getCheckInstance(checkName); + }); + } + + private void initializeChecks() { + UserCheck all = new Role.ALL(); + UserCheck none = new Role.NONE(); + + addRoleCheck("Prefab.Role.All", all); + addRoleCheck(ALL_ROLE, all); + addRoleCheck("Prefab.Role.None", none); + addRoleCheck(NONE_ROLE, none); + + addPrefabCheck("Prefab.Collections.AppendOnly", AppendOnly.class); + addPrefabCheck("Prefab.Collections.RemoveOnly", RemoveOnly.class); + } + + + private void addPrefabCheck(String alias, Class checkClass) { + if (checkNames.containsKey(alias) || checkNames.inverse().containsKey(checkClass)) { + return; + } + + checkNames.put(alias, checkClass); + } + + private static Package getParentPackage(Package pkg) { + return pkg.getParentPackage(); + } + + /** + * Adds a user check for a given role to the dictionary. + * @param role The role associated with the check. + * @param check The instantiated check class. + */ + public void addRoleCheck(String role, UserCheck check) { + roleChecks.put(role, check); + } + + /** + * Returns an instantiated role check for the given role. + * @param role The role associated with the check. + * @return The user check associated with the role. + */ + public UserCheck getRoleCheck(String role) { + return roleChecks.get(role); + } + + /** + * Returns the map of role to their user role check object. + * @return + */ + public Map getRoleChecks() { + return roleChecks; + } + + /** + * Gets all the registered check identifiers. + * @return A set of check identifier strings. + */ + public Set getCheckIdentifiers() { + return Sets.union(roleChecks.keySet(), checkNames.keySet()); + } + + /** + * Cache the simple name of the provided class. + * @param cls the {@code Class} object to be checked + * @return simple name + */ + public static String getSimpleName(Type cls) { + return SIMPLE_NAMES.computeIfAbsent(cls, key -> cls.getSimpleName()); + } + + /** + * Find an arbitrary method. + * + * @param entityClass the entity class + * @param name the name + * @param paramClass the param class + * @return method method + * @throws NoSuchMethodException the no such method exception + */ + public static Method findMethod(Type entityClass, String name, Type... paramClass) + throws NoSuchMethodException { + Method m = entityClass.getMethod(name, paramClass); + int modifiers = m.getModifiers(); + if (Modifier.isAbstract(modifiers) + || m.isAnnotationPresent(Transient.class) + && !m.isAnnotationPresent(ComputedAttribute.class) + && !m.isAnnotationPresent(ComputedRelationship.class)) { + throw new NoSuchMethodException(name); + } + return m; + } + + /** + * Returns an entity binding if the provided class has been bound in the dictionary. + * Otherwise the behavior depends on whether the unbound class is an Entity or not. + * If it is not an Entity, we return an EMPTY_BINDING. This preserves existing behavior for relationships + * which are entities but not bound. Otherwise, we throw an exception - which also preserves behavior + * for unbound non-entities. + * @param entityClass + * @return + */ + public EntityBinding getEntityBinding(Type entityClass) { + + //Common case of no inheritance. This lookup is a performance boost so we don't have to do reflection. + EntityBinding binding = entityBindings.get(entityClass); + if (binding != null) { + return binding; + } + + Type declaredClass = lookupBoundClass(entityClass); + + if (declaredClass != null) { + return entityBindings.get(declaredClass); + } + + //Will throw an exception if entityClass is not an entity. + lookupEntityClass(entityClass); + return EMPTY_BINDING; + } + + /** + * Returns whether or not the ID field for a given model is generated by the persistence layer. + * @param entityClass The model to lookup. + * @return True if the ID field is generated. False otherwise. + */ + public boolean isIdGenerated(Type entityClass) { + return getEntityBinding(entityClass).isIdGenerated(); + } + + /** + * Returns the binding class for a given entity name. + * + * @param entityName entity name + * @return binding class + */ + public Type getEntityClass(String entityName, String version) { + Type 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.jsonApiType.equals(entityName)) + .map(EntityBinding::getEntityClass) + .findFirst() + .orElse(null); + } + return lookup; + } + + /** + * Returns the Include name for a given binding class. + * + * @param entityClass the entity class + * @return binding class + * @see Include + */ + public String getJsonAliasFor(Type entityClass) { + return getEntityBinding(entityClass).jsonApiType; + } + + /** + * Determine if a given (entity class, permission) pair have any permissions defined. + * + * @param resourceClass the entity class + * @param annotationClass the permission annotation + * @return {@code true} if that permission is defined anywhere within the class + */ + public boolean entityHasChecksForPermission(Type resourceClass, Class annotationClass) { + EntityBinding binding = getEntityBinding(resourceClass); + return binding.entityPermissions.hasChecksForPermission(annotationClass); + } + + /** + * Gets the specified permission definition (if any) at the class level. + * + * @param resourceClass the entity to check + * @param annotationClass the permission to look for + * @return a {@code ParseTree} expressing the permissions, if one exists + * or {@code null} if the permission is not specified at a class level + */ + public ParseTree getPermissionsForClass(Type resourceClass, Class annotationClass) { + EntityBinding binding = getEntityBinding(resourceClass); + return binding.entityPermissions.getClassChecksForPermission(annotationClass); + } + + /** + * Gets the specified permission definition (if any) at the class level. + * + * @param resourceClass the entity to check + * @param field the field to inspect + * @param annotationClass the permission to look for + * @return a {@code ParseTree} expressing the permissions, if one exists + * or {@code null} if the permission is not specified on that field + */ + public ParseTree getPermissionsForField(Type resourceClass, + String field, + Class annotationClass) { + EntityBinding binding = getEntityBinding(resourceClass); + return binding.entityPermissions.getFieldChecksForPermission(field, annotationClass); + } + + /** + * Returns the check class mapped to a particular identifier. + * + * @param checkIdentifier the name from the expression string + * @return the {@link Check} class mapped to the identifier. + */ + public Class getCheck(String checkIdentifier) { + return checkNames.computeIfAbsent(checkIdentifier, cls -> { + try { + return Class.forName(checkIdentifier).asSubclass(Check.class); + } catch (ClassNotFoundException | ClassCastException e) { + throw new IllegalArgumentException( + "Could not instantiate specified check '" + checkIdentifier + "'.", e); + } + }); + } + + /** + * Returns the check mapped to a particular identifier. + * + * @param checkIdentifier the name from the expression string + * @return the {@link Check} mapped to the identifier. + */ + public Check getCheckInstance(String checkIdentifier) { + //Role checks may contain the same class for different checks. + if (roleChecks.containsKey(checkIdentifier)) { + return roleChecks.get(checkIdentifier); + } + + Class checkClass = getCheck(checkIdentifier); + + Check check; + if (checkInstances.containsKey(checkClass)) { + check = checkInstances.get(checkClass); + } else { + check = injector.instantiate(checkClass); + injector.inject(check); + checkInstances.put(checkClass, check); + } + + return check; + } + + /** + * 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(Type entityClass) { + return getEntityBinding(entityClass).inheritedTypes.stream() + .filter(entityBindings::containsKey) + .collect(Collectors.toList()); + } + + /** + * Get a list of inherited entities from a particular entity. + * Namely, the list of entities inheriting from the provided class. + * + * @param entityClass Entity class + * @return List of all inherited entity types + */ + public List> getSubclassingEntities(Type 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. + * @param checkClass The class to lookup + * @return the friendly name of the check. + */ + public String getCheckIdentifier(Class checkClass) { + String identifier = checkNames.inverse().get(checkClass); + + if (identifier != null) { + return identifier; + } + + if (UserCheck.class.isAssignableFrom(checkClass)) { + for (Map.Entry entry : roleChecks.entrySet()) { + UserCheck check = entry.getValue(); + String name = entry.getKey(); + if (check.getClass().equals(checkClass)) { + return name; + } + } + } + + return checkClass.getName(); + } + + /** + * Returns the name of the id field. + * + * @param entityClass Entity class + * @return id field name + */ + public String getIdFieldName(Type entityClass) { + return getEntityBinding(entityClass).getIdFieldName(); + } + + /** + * Returns whether the entire entity uses Field or Property level access. + * @param entityClass Entity Class + * @return The JPA Access Type + */ + public AccessType getAccessType(Type entityClass) { + return getEntityBinding(entityClass).getAccessType(); + } + + /** + * Get all bound model classes. + * + * @return the bound classes + */ + public Set> getBoundClasses() { + return getBoundClasses(true); + } + + /** + * Get all bound classes. + * @param elideModelsOnly Restrict to only Elide models (skip complex embedded types). + * + * @return the bound classes + */ + public Set> getBoundClasses(boolean elideModelsOnly) { + return entityBindings.values().stream() + .filter(binding -> elideModelsOnly ? binding.isElideModel() : true) + .map(EntityBinding::getEntityClass) + .collect(Collectors.toSet()); + } + + /** + * Get all bound classes for a particular API version. + * @param apiVersion The API version + * @param elideModelsOnly Restrict to only Elide models (skip complex embedded types). + * + * @return the bound classes + */ + public Set> getBoundClassesByVersion(String apiVersion, boolean elideModelsOnly) { + return entityBindings.values().stream() + .filter(binding -> elideModelsOnly ? binding.isElideModel() : true) + .filter(binding -> + binding.getApiVersion().equals(apiVersion) + || binding.entityClass.getName().startsWith(ELIDE_PACKAGE_PREFIX) + ) + .map(EntityBinding::getEntityClass) + .collect(Collectors.toSet()); + } + + /** + * Get all bound classes for a particular API version. + * @param apiVersion The API version + * + * @return the bound classes + */ + public Set> getBoundClassesByVersion(String apiVersion) { + return getBoundClassesByVersion(apiVersion, true); + } + + /** + * Get all model bindings. + * + * @return the bindings + */ + public Set getBindings() { + return getBindings(true); + } + + /** + * Get all bindings. + * @param elideModelsOnly Restrict to only Elide models (skip complex embedded types). + * + * @return the bindings + */ + public Set getBindings(boolean elideModelsOnly) { + return entityBindings.values() + .stream() + .filter(binding -> elideModelsOnly ? binding.isElideModel() : true) + .collect(Collectors.toSet()); + } + + /** + * Get the check mappings. + * @return a map of check mappings this dictionary knows about + */ + public Map> getCheckMappings() { + return checkNames; + } + + /** + * Get the list of attribute names for an entity. + * + * @param entityClass entity name + * @return List of attribute names for entity + */ + public List getAttributes(Type entityClass) { + return getEntityBinding(entityClass).apiAttributes; + } + + /** + * Get the Injector for this dictionary. + * + * @return Injector instance. + */ + public Injector getInjector() { + return injector; + } + + /** + * Get the list of attribute names for an entity. + * + * @param entity entity instance + * @return List of attribute names for entity + */ + public List getAttributes(Object entity) { + return getAttributes(getType(entity)); + } + + /** + * Get the list of relationship names for an entity. + * + * @param entityClass entity name + * @return List of relationship names for entity + */ + public List getRelationships(Type entityClass) { + return getEntityBinding(entityClass).apiRelationships; + } + + /** + * Get the list of relationship names for an entity. + * + * @param entity entity instance + * @return List of relationship names for entity + */ + public List getRelationships(Object entity) { + return getRelationships(getType(entity)); + } + + /** + * Get a list of elide-bound relationships. + * + * @param entityClass Entity class to find relationships for + * @return List of elide-bound relationship names. + */ + public List getElideBoundRelationships(Type entityClass) { + return getRelationships(entityClass).stream() + .filter(relationName -> getBoundClasses().contains(getParameterizedType(entityClass, relationName))) + .collect(Collectors.toList()); + } + + /** + * Get a list of elide-bound relationships. + * + * @param entity Entity instance to find relationships for + * @return List of elide-bound relationship names. + */ + public List getElideBoundRelationships(Object entity) { + return getElideBoundRelationships(getType(entity)); + } + + /** + * Determine whether or not a method is request scopeable. + * + * @param entity Entity instance + * @param method Method on entity to check + * @return True if method accepts a RequestScope, false otherwise. + */ + public boolean isMethodRequestScopeable(Object entity, Method method) { + return isMethodRequestScopeable(getType(entity), method); + } + + /** + * Determine whether or not a method is request scopeable. + * + * @param entityClass Entity to check + * @param method Method on entity to check + * @return True if method accepts a RequestScope, false otherwise. + */ + public boolean isMethodRequestScopeable(Type entityClass, Method method) { + return getEntityBinding(entityClass).requestScopeableMethods.getOrDefault(method, false); + } + + /** + * Get a list of all fields including both relationships and attributes (but excluding hidden fields). + * + * @param entityClass entity name + * @return List of all exposed fields. + */ + public List getAllExposedFields(Type entityClass) { + List fields = new ArrayList<>(); + + List attrs = getAttributes(entityClass); + List rels = getRelationships(entityClass); + + if (attrs != null) { + fields.addAll(attrs); + } + + if (rels != null) { + fields.addAll(rels); + } + + return fields; + } + + /** + * Get a list of all fields including both relationships and attributes. + * + * @param entity entity + * @return List of all fields. + */ + public List getAllExposedFields(Object entity) { + return getAllExposedFields(getType(entity)); + } + + /** + * Get the type of relationship from a relation. + * + * @param cls Entity class + * @param relation Name of relationship field + * @return Relationship type. RelationshipType.NONE if is none found. + */ + public RelationshipType getRelationshipType(Type cls, String relation) { + final ConcurrentHashMap types = getEntityBinding(cls).relationshipTypes; + if (types == null) { + return RelationshipType.NONE; + } + final RelationshipType type = types.get(relation); + return (type == null) ? RelationshipType.NONE : type; + } + + /** + * If a relationship is bidirectional, returns the name of the peer relationship in the peer entity. + * + * @param cls the cls + * @param relation the relation + * @return relation inverse + */ + public String getRelationInverse(Type cls, String relation) { + final EntityBinding clsBinding = getEntityBinding(cls); + final ConcurrentHashMap mappings = clsBinding.relationshipToInverse; + if (mappings != null) { + final String mapping = mappings.get(relation); + + if (mapping != null && !"".equals(mapping)) { + return mapping; + } + } + + /* + * This could be the owning side of the relation. Let's see if the entity referenced in the relation + * has a bidirectional reference that is mapped to the given relation. + */ + final Type inverseType = getParameterizedType(cls, relation); + final ConcurrentHashMap inverseMappings = + getEntityBinding(inverseType).relationshipToInverse; + + for (Map.Entry inverseMapping : inverseMappings.entrySet()) { + String inverseRelationName = inverseMapping.getKey(); + String inverseMappedBy = inverseMapping.getValue(); + + if (relation.equals(inverseMappedBy) + && getParameterizedType(inverseType, inverseRelationName).equals(clsBinding.entityClass)) { + return inverseRelationName; + } + + } + return ""; + } + + /** + * Get the type of relationship from a relation. + * + * @param entity Entity instance + * @param relation Name of relationship field + * @return Relationship type. RelationshipType.NONE if is none found. + */ + public RelationshipType getRelationshipType(Object entity, String relation) { + return getRelationshipType(getType(entity), relation); + } + + /** + * Get a type for a field on an entity. + *

+ * If this method is called on a bean such as the following + *

+     * {@code
+     * public class Address {
+     *     {@literal @}Id
+     *     private Long id
+     *
+     *     private String street1;
+     *
+     *     private String street2;
+     * }
+     * }
+     * 
+ * then + *
+     * {@code
+     * getType(Address.class, "id") = Long.class
+     * getType(Address.class, "street1") = String.class
+     * getType(Address.class, "street2") = String.class
+     * }
+     * 
+ * But if the ID field is not "id" and there is no such non-ID field called "id", i.e. + *
+     * {@code
+     * public class Address {
+     *     {@literal @}Id
+     *     private Long surrogateKey
+     *
+     *     private String street1;
+     *
+     *     private String street2;
+     * }
+     * }
+     * 
+ * then + *
+     * {@code
+     * getType(Address.class, "id") = Long.class
+     * getType(Address.class, "surrogateKey") = Long.class
+     * getType(Address.class, "street1") = String.class
+     * getType(Address.class, "street2") = String.class
+     * }
+     * 
+ * JSON-API spec does not allow "id" as non-ID field name. If, therefore, there is a non-ID field called "id", + * calling this method has undefined behavior + * + * @param entityClass Entity class + * @param identifier Identifier/Field to lookup type + * @return Type of entity + */ + public Type getType(Type entityClass, String identifier) { + if (identifier.equals(REGULAR_ID_NAME)) { + return getEntityBinding(entityClass).getIdType(); + } + + ConcurrentHashMap> fieldTypes = getEntityBinding(entityClass).fieldsToTypes; + return fieldTypes == null ? null : fieldTypes.get(identifier); + } + + /** + * Get a type for a field on an entity. + * + * @param entity Entity instance + * @param identifier Field to lookup type + * @return Type of entity + */ + public Type getType(Object entity, String identifier) { + return getType(getType(entity), identifier); + } + + /** + * Retrieve the parameterized type for the given field. + * + * @param entityClass the entity class + * @param identifier the identifier + * @return Entity type for field otherwise null. + */ + public Type getParameterizedType(Type entityClass, String identifier) { + return getParameterizedType(entityClass, identifier, 0); + } + + /** + * Retrieve the parameterized type for the given field. + * + * @param entityClass the entity class + * @param identifier the identifier/field name + * @param paramIndex the index of the parameterization + * @return Entity type for field otherwise null. + */ + public Type getParameterizedType(Type entityClass, String identifier, int paramIndex) { + ConcurrentHashMap fieldOrMethods = getEntityBinding(entityClass).fieldsToValues; + if (fieldOrMethods == null) { + return null; + } + AccessibleObject fieldOrMethod = fieldOrMethods.get(identifier); + if (fieldOrMethod == null) { + return null; + } + + return EntityBinding.getFieldType(entityClass, fieldOrMethod, Optional.of(paramIndex)); + } + + /** + * Retrieve the parameterized type for the given field. + * + * @param entity Entity instance + * @param identifier Field to lookup + * @return Entity type for field otherwise null. + */ + public Type getParameterizedType(Object entity, String identifier) { + return getParameterizedType(getType(entity), identifier); + } + + /** + * Retrieve the parameterized type for the given field. + * + * @param entity Entity instance + * @param identifier Field to lookup + * @param paramIndex the index of the parameterization + * @return Entity type for field otherwise null. + */ + public Type getParameterizedType(Object entity, String identifier, int paramIndex) { + return getParameterizedType(getType(entity), identifier, paramIndex); + } + + /** + * Get the true field/method name from an alias. + * + * @param entityClass Entity name + * @param alias Alias to convert + * @return Real field/method name as a string. null if not found. + */ + public String getNameFromAlias(Type entityClass, String alias) { + ConcurrentHashMap map = getEntityBinding(entityClass).aliasesToFields; + if (map != null) { + return map.get(alias); + } + return null; + } + + /** + * Get the true field/method name from an alias. + * + * @param entity Entity instance + * @param alias Alias to convert + * @return Real field/method name as a string. null if not found. + */ + public String getNameFromAlias(Object entity, String alias) { + return getNameFromAlias(getType(entity), alias); + } + + /** + * Initialize an entity. + * + * @param the type parameter + * @param entity Entity to initialize + */ + public void initializeEntity(T entity) { + Type type = getType(entity); + if (entity != null) { + EntityBinding binding = getEntityBinding(type); + + if (binding.isInjected()) { + injector.inject(entity); + } + } + } + + /** + * Returns whether or not an entity is shareable. + * + * @param entityClass the entity type to check for the shareable permissions + * @return true if entityClass is shareable. False otherwise. + */ + public boolean isTransferable(Type entityClass) { + NonTransferable nonTransferable = getAnnotation(entityClass, NonTransferable.class); + + return (nonTransferable == null || !nonTransferable.enabled()); + } + + /** + * Returns whether or not an entity can ever be shared post creation. + * + * @param entityClass the entity type to check for the shareable permissions + * @return true if entityClass can never be shared post creation. False otherwise. + */ + public boolean isStrictNonTransferable(Type entityClass) { + NonTransferable nonTransferable = getAnnotation(entityClass, NonTransferable.class); + + return (nonTransferable != null && nonTransferable.enabled() && nonTransferable.strict()); + } + + /** + * Add given Entity bean to dictionary. + * + * @param cls Entity bean class + */ + public void bindEntity(Class cls) { + bindEntity(ClassType.of(cls)); + } + + /** + * Add given Entity bean to dictionary. + * + * @param cls Entity bean class + */ + public void bindEntity(Type cls) { + bindEntity(cls, unused -> false); + } + + /** + * Add given Entity bean to dictionary. + * + * @param cls Entity bean class + * @param isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. + */ + public void bindEntity(Class cls, Predicate isFieldHidden) { + bindEntity(ClassType.of(cls), isFieldHidden); + } + + /** + * Add given Entity bean to dictionary. + * + * @param cls Entity bean class + * @param isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. + */ + public void bindEntity(Type cls, Predicate isFieldHidden) { + Type declaredClass = lookupIncludeClass(cls); + + if (entitiesToExclude.contains(declaredClass)) { + //Exclude Entity + return; + } + + if (declaredClass == null) { + log.trace("Missing include or excluded class {}", cls.getName()); + return; + } + + if (isClassBound(declaredClass)) { + //Ignore duplicate bindings. + return; + } + + String type = getEntityName(declaredClass); + String version = getModelVersion(declaredClass); + + bindJsonApiToEntity.put(Pair.of(type, version), declaredClass); + apiVersions.add(version); + EntityBinding binding = new EntityBinding(injector, declaredClass, type, version, isFieldHidden); + entityBindings.put(declaredClass, binding); + + Include include = (Include) getFirstAnnotation(declaredClass, Arrays.asList(Include.class)); + if (include != null && include.rootLevel()) { + bindEntityRoots.add(declaredClass); + } + + bindLegacyHooks(binding); + discoverEmbeddedTypeBindings(declaredClass); + } + + /** + * Add an EntityBinding instance to dictionary. + * + * @param entityBinding EntityBinding instance + */ + public void bindEntity(EntityBinding entityBinding) { + Type declaredClass = entityBinding.entityClass; + + if (entitiesToExclude.contains(declaredClass)) { + //Exclude Entity + return; + } + + 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 != null && include.rootLevel()) { + bindEntityRoots.add(declaredClass); + } + } + + /** + * Add a permissionExecutorGenerator to the provided class. + * @param clz Entity model class + * @param permissionExecutorFunction Function that given a request scope returns permissionExecutor + */ + public void bindPermissionExecutor(Class clz, + Function permissionExecutorFunction) { + bindPermissionExecutor(ClassType.of(clz), permissionExecutorFunction); + } + + /** + * Add a permissionExecutorGenerator to the provided class. + * @param clz Entity model type + * @param permissionExecutorFunction Function that given a request scope returns permissionExecutor + */ + public void bindPermissionExecutor(Type clz, + Function permissionExecutorFunction) { + entityPermissionExecutor.put(lookupBoundClass(clz), permissionExecutorFunction); + } + + /** + * Create a PermissionExecutor from list of bound permissionExecutorGenerator. + * @param scope - request scope to generate permission executor. + * @return Map of bound model type to its permission executor object. + */ + public Map, PermissionExecutor> buildPermissionExecutors(RequestScope scope) { + return entityPermissionExecutor.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().apply(scope) + )); + } + + /** + * Return annotation from class, parents or package. + * + * @param record the record + * @param annotationClass the annotation class + * @param
genericClass + * @return the annotation + */ + public A getAnnotation(PersistentResource record, Class annotationClass) { + return getAnnotation(record.getResourceType(), annotationClass); + } + + /** + * Return annotation from class, parents or package. + * + * @param recordClass the record class + * @param annotationClass the annotation class + * @param genericClass + * @return the annotation + */ + public A getAnnotation(Type recordClass, Class annotationClass) { + return getEntityBinding(recordClass).getAnnotation(annotationClass); + } + + /** + * Returns whether a class (including superclasses) or any of its + * fields (attributes/relationships) has a given annotation. + * @param recordClass The elide model to check. + * @param annotationClass The annotation to search for. + * @param The annotation type. + * @return True if the model is decorated with the annotation. False otherwise. + */ + public boolean hasAnnotation(Type recordClass, Class annotationClass) { + if (this.getAnnotation(recordClass, annotationClass) != null) { + return true; + } + + for (String fieldName : getEntityBinding(recordClass).fieldsToValues.keySet()) { + if (this.getAttributeOrRelationAnnotation(recordClass, annotationClass, fieldName) != null) { + return true; + } + } + + return false; + } + + /** + * Return annotation from class for provided method. + * @param recordClass the record class + * @param method the method + * @param annotationClass the annotation class + * @param genericClass + * @return the annotation + */ + public A getMethodAnnotation(Type recordClass, String method, Class annotationClass) { + return getEntityBinding(recordClass).getMethodAnnotation(annotationClass, method); + } + + public Collection getTriggers(Type cls, + Operation op, + TransactionPhase phase, + String fieldName) { + return getEntityBinding(cls).getTriggers(op, phase, fieldName); + } + + public Collection getTriggers(Type cls, + Operation op, + TransactionPhase phase) { + return getEntityBinding(cls).getTriggers(op, phase); + } + + /** + * Return a single annotation from field or accessor method. + * + * @param entityClass the entity class + * @param annotationClass given annotation type + * @param identifier the identifier + * @param genericClass + * @return annotation found + */ + public A getAttributeOrRelationAnnotation(Type entityClass, + Class annotationClass, + String identifier) { + AccessibleObject fieldOrMethod = getEntityBinding(entityClass).fieldsToValues.get(identifier); + if (fieldOrMethod == null) { + return null; + } + return fieldOrMethod.getAnnotation(annotationClass); + } + + /** + * Return multiple annotations from field or accessor method. + * + * @param the type parameter + * @param entityClass the entity class + * @param annotationClass given annotation type + * @param identifier the identifier + * @return annotation found or null if none found + */ + public A[] getAttributeOrRelationAnnotations(Type entityClass, + Class annotationClass, + String identifier) { + AccessibleObject fieldOrMethod = getEntityBinding(entityClass).fieldsToValues.get(identifier); + if (fieldOrMethod == null) { + return null; + } + return fieldOrMethod.getAnnotationsByType(annotationClass); + } + + /** + * Return first matching annotation from class, parents or package. + * + * @param entityClass Entity class type + * @param annotationClassList List of sought annotations + * @return annotation found + */ + public static Annotation getFirstAnnotation(Type entityClass, + List> annotationClassList) { + Annotation annotation = null; + for (Type cls = entityClass; annotation == null && cls != null; cls = cls.getSuperclass()) { + for (Class annotationClass : annotationClassList) { + annotation = cls.getDeclaredAnnotation(annotationClass); + if (annotation != null) { + 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(Type 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) { + annotation = pkg.getDeclaredAnnotation(annotationClass); + if (annotation != null) { + break; + } + } + } + return annotation; + } + + /** + * Is root. + * + * @param entityClass the entity class + * @return the boolean + */ + public boolean isRoot(Type entityClass) { + return bindEntityRoots.contains(entityClass); + } + + /** + * Gets id. + * + * @param value the value + * @return the id + */ + public String getId(Object value) { + if (value == null) { + return null; + } + try { + AccessibleObject idField = null; + + Type valueClass = getType(value); + + for (; idField == null && valueClass != null; valueClass = valueClass.getSuperclass()) { + try { + idField = getEntityBinding(valueClass).getIdField(); + } catch (NullPointerException e) { + log.warn("Class: {} ID Field: {}", valueClass.getSimpleName(), idField); + } + } + + Type idClass; + Object idValue; + if (idField instanceof Field) { + idValue = ((Field) idField).get(value); + idClass = ((Field) idField).getType(); + } else if (idField instanceof Method) { + idValue = ((Method) idField).invoke(value, (Object[]) null); + idClass = ((Method) idField).getReturnType(); + } else { + return null; + } + + Serde serde = serdeLookup.apply(((ClassType) idClass).getCls()); + if (serde != null) { + return String.valueOf(serde.serialize(idValue)); + } + + return String.valueOf(idValue); + } catch (IllegalArgumentException | IllegalAccessException | InvocationTargetException e) { + return null; + } + } + + /** + * Returns type of id field. + * + * @param entityClass the entity class + * @return ID type + */ + public Type getIdType(Type entityClass) { + return getEntityBinding(entityClass).getIdType(); + } + + /** + * Returns annotations applied to the ID field. + * + * @param value the value + * @return Collection of Annotations + */ + public Collection getIdAnnotations(Object value) { + if (value == null) { + return null; + } + + AccessibleObject idField = getEntityBinding(getType(value)).getIdField(); + if (idField != null) { + return Arrays.asList(idField.getDeclaredAnnotations()); + } + + return Collections.emptyList(); + } + + /** + * Follow for this class or super-class for JPA {@link Entity} annotation. + * + * @param objClass provided class + * @return class with Entity annotation + */ + public Type lookupEntityClass(Type objClass) { + Type declaringClass = lookupAnnotationDeclarationClass(objClass, Entity.class); + if (declaringClass != null) { + return declaringClass; + } + throw new IllegalArgumentException("Unbound Entity " + objClass); + } + + /** + * Follow for this class or super-class for Include annotation. + * + * @param objClass provided class + * @return class with Include annotation or + */ + public Type lookupIncludeClass(Type objClass) { + Annotation first = getFirstAnnotation(objClass, Arrays.asList(Exclude.class, Include.class)); + if (first instanceof Include) { + Type 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; + } + + /** + * Search a class hierarchy to find the first instance of a declared annotation. + * @param objClass The class to start searching. + * @param annotationClass The annotation to search for. + * @return The class which declares the annotation or null. + */ + public static Type lookupAnnotationDeclarationClass(Type objClass, + Class annotationClass) { + for (Type cls = objClass; cls != null; cls = cls.getSuperclass()) { + if (cls.getDeclaredAnnotation(annotationClass) != null) { + return cls; + } + } + return null; + } + + + /** + * Return bound entity or null. + * + * @param objClass provided class + * @return Bound class. + */ + public Type lookupBoundClass(Type objClass) { + //Common case - we can avoid reflection by checking the map ... + EntityBinding binding = entityBindings.getOrDefault(objClass, EMPTY_BINDING); + if (binding != EMPTY_BINDING) { + return binding.entityClass; + } + + Type declaredClass = lookupIncludeClass(objClass); + if (declaredClass == null) { + return null; + } + + binding = entityBindings.getOrDefault(declaredClass, EMPTY_BINDING); + if (binding != EMPTY_BINDING) { + return binding.entityClass; + } + + try { + //Special Case for ORM proxied objects. If the class is a proxy, + //and it is unbound, try the superclass. + return lookupEntityClass(declaredClass.getSuperclass()); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * Return if the class has been bound or not. Only safe to call while binding an entity (does not consider + * ORM proxy objects). + * + * @param objClass provided class + * @return true if the class is already bound. + */ + private boolean isClassBound(Type 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(Type objClass) { + try { + lookupEntityClass(objClass); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + /** + * Retrieve the accessible object for a field from a target object. + * + * @param target the object to get + * @param fieldName the field name to get or invoke equivalent get method + * @return the value + */ + public AccessibleObject getAccessibleObject(Object target, String fieldName) { + return getAccessibleObject(getType(target), fieldName); + } + + public boolean isComputed(Type entityClass, String fieldName) { + AccessibleObject fieldOrMethod = getAccessibleObject(entityClass, fieldName); + + return fieldOrMethod != null + && (fieldOrMethod.isAnnotationPresent(ComputedAttribute.class) + || fieldOrMethod.isAnnotationPresent(ComputedRelationship.class)); + } + + /** + * Retrieve the accessible object for a field. + * + * @param targetClass the object to get + * @param fieldName the field name to get or invoke equivalent get method + * @return the value + */ + public AccessibleObject getAccessibleObject(Type targetClass, String fieldName) { + return getEntityBinding(targetClass).fieldsToValues.get(fieldName); + } + + /** + * Retrieve fields from an object containing a particular type. + * + * @param targetClass Class to search for fields + * @param targetType Type of fields to find + * @return Set containing field names + */ + public Set getFieldsOfType(Type targetClass, Type targetType) { + HashSet fields = new HashSet<>(); + for (String field : getAllExposedFields(targetClass)) { + if (getParameterizedType(targetClass, field).equals(targetType)) { + fields.add(field); + } + } + return fields; + } + + public boolean isRelation(Type entityClass, String relationName) { + return getEntityBinding(entityClass).apiRelationships.contains(relationName); + } + + public boolean isAttribute(Type entityClass, String attributeName) { + return getEntityBinding(entityClass).apiAttributes.contains(attributeName); + } + + /** + * Scan for security checks and automatically bind them to the dictionary. + */ + public void scanForSecurityChecks() { + + // Logic is based on https://github.com/illyasviel/elide-spring-boot/blob/master + // /elide-spring-boot-autoconfigure/src/main/java/org/illyasviel/elide + // /spring/boot/autoconfigure/ElideAutoConfiguration.java + + Set> classes = scanner.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 (CollectionUtils.isEmpty(classes)) { + return; + } + + classes.forEach(this::addSecurityCheck); + } + + /** + * Add security checks and bind them to the dictionary. + * @param cls Security check class. + */ + public void addSecurityCheck(Class cls) { + if (Check.class.isAssignableFrom(cls)) { + SecurityCheck securityCheckMeta = cls.getAnnotation(SecurityCheck.class); + log.debug("Register Elide Check [{}] with expression [{}]", + cls.getCanonicalName(), securityCheckMeta.value()); + checkNames.put(securityCheckMeta.value(), cls.asSubclass(Check.class)); + + //Populate check instance. + getCheckInstance(securityCheckMeta.value()); + } else { + throw new IllegalStateException("Class annotated with SecurityCheck is not a Check"); + } + } + + /** + * 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 CREATE, or UPDATE. + * @param entityClass The entity that triggers the lifecycle hook. + * @param fieldOrMethodName The name of the field or method. + * @param operation CREATE, or UPDATE + * @param phase PRESECURITY, PRECOMMIT, or POSTCOMMIT + * @param hook The callback to invoke. + */ + public void bindTrigger(Class entityClass, + String fieldOrMethodName, + Operation operation, + TransactionPhase phase, + LifeCycleHook hook) { + bindTrigger(ClassType.of(entityClass), fieldOrMethodName, operation, phase, hook); + } + + /** + * 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 CREATE, or UPDATE. + * @param entityClass The entity that triggers the lifecycle hook. + * @param fieldOrMethodName The name of the field or method. + * @param operation CREATE, or UPDATE + * @param phase PRESECURITY, PRECOMMIT, or POSTCOMMIT + * @param hook The callback to invoke. + */ + public void bindTrigger(Type entityClass, + String fieldOrMethodName, + Operation operation, + TransactionPhase phase, + LifeCycleHook hook) { + bindIfUnbound(entityClass); + + getEntityBinding(entityClass).bindTrigger(operation, phase, fieldOrMethodName, hook); + } + + /** + * Binds a lifecycle hook to a particular entity class. The hook will either be called: + * - A single time single time per request per class CREATE, UPDATE, or DELETE. + * - Multiple times per request per field CREATE, or UPDATE. + * + * The behavior is determined by the value of the {@code allowMultipleInvocations} flag. + * @param entityClass The entity that triggers the lifecycle hook. + * @param operation CREATE, 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. + */ + public void bindTrigger(Class entityClass, + Operation operation, + TransactionPhase phase, + LifeCycleHook hook, + boolean allowMultipleInvocations) { + bindTrigger(ClassType.of(entityClass), operation, phase, hook, allowMultipleInvocations); + } + + /** + * Binds a lifecycle hook to a particular entity class. The hook will either be called: + * - A single time single time per request per class CREATE, UPDATE, or DELETE. + * - Multiple times per request per field CREATE, or UPDATE. + * + * The behavior is determined by the value of the {@code allowMultipleInvocations} flag. + * @param entityClass The entity that triggers the lifecycle hook. + * @param operation CREATE, 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. + */ + public void bindTrigger(Type entityClass, + Operation operation, + TransactionPhase phase, + LifeCycleHook hook, + boolean allowMultipleInvocations) { + bindIfUnbound(entityClass); + + if (allowMultipleInvocations) { + getEntityBinding(entityClass).bindTrigger(operation, phase, hook); + } else { + getEntityBinding(entityClass).bindTrigger(operation, phase, PersistentResource.CLASS_NO_FIELD, hook); + } + } + + /** + * Returns true if the relationship cascades deletes and false otherwise. + * @param targetClass The class which owns the relationship. + * @param fieldName The relationship + * @return true or false + */ + public boolean cascadeDeletes(Type targetClass, String fieldName) { + CascadeType [] cascadeTypes = + getEntityBinding(targetClass).relationshipToCascadeTypes.getOrDefault(fieldName, new CascadeType[0]); + + for (CascadeType cascadeType : cascadeTypes) { + if (cascadeType == CascadeType.ALL || cascadeType == CascadeType.REMOVE) { + return true; + } + } + return false; + } + + /** + * Walks the entity graph and performs a transform function on each element. + * @param entities The roots of the entity graph. + * @param transform The function to transform each entity class into a result. + * @param The result type. + * @return The collection of results. + */ + public List walkEntityGraph(Set> entities, Function, T> transform) { + ArrayList results = new ArrayList<>(); + Queue> toVisit = new ArrayDeque<>(entities); + Set> visited = new HashSet<>(); + while (! toVisit.isEmpty()) { + Type clazz = toVisit.remove(); + results.add(transform.apply(clazz)); + visited.add(clazz); + + for (String relationship : getElideBoundRelationships(clazz)) { + Type relationshipClass = getParameterizedType(clazz, relationship); + + + if (lookupBoundClass(relationshipClass) == null) { + /* The relationship hasn't been bound */ + continue; + } + + if (!visited.contains(relationshipClass)) { + toVisit.add(relationshipClass); + } + } + } + return results; + } + + /** + * Returns whether or not a class is already bound. + * @param cls The class to verify. + * @return true if the class is bound. False otherwise. + */ + public boolean hasBinding(Type cls) { + return entityBindings.values().stream() + .anyMatch(binding -> binding.entityClass.equals(cls)); + } + + /** + * Invoke the get[fieldName] method on the target object OR get the field with the corresponding name. + * @param target the object to get + * @param fieldName the field name to get or invoke equivalent get method + * @return the value + */ + public Object getValue(Object target, String fieldName, RequestScope scope) { + AccessibleObject accessor = getAccessibleObject(target, fieldName); + try { + if (accessor instanceof Method) { + // Pass RequestScope into @Computed fields if requested + if (isMethodRequestScopeable(target, (Method) accessor)) { + return ((Method) accessor).invoke(target, scope); + } + return ((Method) accessor).invoke(target); + } + if (accessor instanceof Field) { + return ((Field) accessor).get(target); + } + } catch (IllegalAccessException e) { + throw new InvalidAttributeException(fieldName, getJsonAliasFor(getType(target)), e); + } catch (InvocationTargetException e) { + throw handleInvocationTargetException(e); + } + throw new InvalidAttributeException(fieldName, getJsonAliasFor(getType(target))); + } + + /** + * 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(getType(target))), 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 + */ + public void setValue(Object target, String fieldName, Object value) { + Type targetClass = getType(target); + String targetType = getJsonAliasFor(targetClass); + + String fieldAlias = fieldName; + try { + Type fieldClass = getType(targetClass, fieldName); + String realName = getNameFromAlias(target, fieldName); + fieldAlias = (realName != null) ? realName : fieldName; + String setMethod = "set" + StringUtils.capitalize(fieldAlias); + Method method = EntityDictionary.findMethod(targetClass, setMethod, fieldClass); + method.invoke(target, coerce(target, value, fieldAlias, fieldClass)); + } catch (IllegalAccessException e) { + throw new InvalidAttributeException(fieldAlias, targetType, e); + } catch (InvocationTargetException e) { + throw handleInvocationTargetException(e); + } catch (IllegalArgumentException | NoSuchMethodException noMethod) { + AccessibleObject accessor = getAccessibleObject(target, fieldAlias); + if (accessor != null && accessor instanceof Field) { + Field field = (Field) accessor; + try { + field.set(target, coerce(target, value, fieldAlias, field.getType())); + } catch (IllegalAccessException noField) { + throw new InvalidAttributeException(fieldAlias, targetType, noField); + } + } else { + throw new InvalidAttributeException(fieldAlias, targetType); + } + } + } + + /** + * Handle an invocation target exception. + * + * @param e Exception the exception encountered while reflecting on an object's field + * @return Equivalent runtime exception + */ + private static RuntimeException handleInvocationTargetException(InvocationTargetException e) { + Throwable exception = e.getTargetException(); + if (exception instanceof HttpStatusException || exception instanceof WebApplicationException) { + return (RuntimeException) exception; + } + log.error("Caught an unexpected exception (rethrowing as internal server error)", e); + return new InternalServerErrorException("Unexpected exception caught", e); + } + + /** + * Coerce provided value into expected class type. + * + * @param target The model instance which owns the field being coerced. + * @param value The value being coerced. + * @param fieldName the field name in the owning model instance. + * @param fieldType expected class type + * @return coerced value + */ + public Object coerce(Object target, Object value, String fieldName, Type fieldType) { + + Class fieldClass = null; + if (fieldType != null) { + Preconditions.checkState(fieldType instanceof ClassType); + fieldClass = ((ClassType) fieldType).getCls(); + + if (COLLECTION_TYPE.isAssignableFrom(fieldType) && value instanceof Collection) { + return coerceCollection(target, (Collection) value, fieldName, fieldClass); + } + + if (MAP_TYPE.isAssignableFrom(fieldType) && value instanceof Map) { + return coerceMap(target, (Map) value, fieldName); + } + } + + return CoerceUtil.coerce(value, fieldClass); + } + + private Collection coerceCollection(Object target, Collection values, String fieldName, Class fieldClass) { + ClassType providedType = (ClassType) getParameterizedType(target, fieldName); + + // check if collection is of and contains the correct types + if (fieldClass.isAssignableFrom(values.getClass())) { + boolean valid = true; + for (Object member : values) { + if (member != null && !providedType.isAssignableFrom(getType(member))) { + valid = false; + break; + } + } + if (valid) { + return values; + } + } + + ArrayList list = new ArrayList<>(values.size()); + for (Object member : values) { + list.add(CoerceUtil.coerce(member, providedType.getCls())); + } + + if (Set.class.isAssignableFrom(fieldClass)) { + return new LinkedHashSet<>(list); + } + + return list; + } + + private Map coerceMap(Object target, Map values, String fieldName) { + Class keyType = ((ClassType) getParameterizedType(target, fieldName, 0)).getCls(); + Class valueType = ((ClassType) getParameterizedType(target, fieldName, 1)).getCls(); + + // Verify the existing Map + if (isValidParameterizedMap(values, keyType, valueType)) { + return values; + } + + LinkedHashMap result = new LinkedHashMap<>(values.size()); + for (Map.Entry entry : values.entrySet()) { + result.put(CoerceUtil.coerce(entry.getKey(), keyType), CoerceUtil.coerce(entry.getValue(), valueType)); + } + + 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( + Type 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(Type cls, String fieldName) { + return getAllExposedFields(cls).contains(fieldName); + } + + private boolean isValidParameterizedMap(Map values, Class keyType, Class valueType) { + for (Map.Entry entry : values.entrySet()) { + Object key = entry.getKey(); + Object value = entry.getValue(); + if ((key != null && !keyType.isAssignableFrom(key.getClass())) + || (value != null && !valueType.isAssignableFrom(value.getClass()))) { + return false; + } + } + return true; + } + + /** + * Binds the entity class if not yet bound. + * @param entityClass the class to bind. + */ + private void bindIfUnbound(Type 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(Type 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(Type cls, String attributeName, ArgumentType argument) { + this.addArgumentsToAttribute(cls, attributeName, Sets.newHashSet(argument)); + } + + /** + * Add a single argument to the Entity. + * @param cls The entity + * @param argument A single argument + */ + public void addArgumentToEntity(Type cls, ArgumentType argument) { + getEntityBinding(cls).addArgumentToEntity(argument); + } + + /** + * Returns the Collection of all arguments of an attribute. + * @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(Type cls, String attributeName) { + return entityBindings.getOrDefault(cls, EMPTY_BINDING).getAttributeArguments(attributeName); + } + + /** + * Returns the Collection of all arguments of an entity. + * @param cls The entity + * @return A Set of ArgumentType for the given entity. + */ + public Set getEntityArguments(Type cls) { + return entityBindings.getOrDefault(cls, EMPTY_BINDING).getEntityArguments(); + } + + /** + * Get column name using JPA. + * + * @param cls The entity class. + * @param fieldName The entity attribute. + * @return The jpa column name. + */ + public String getAnnotatedColumnName(Type 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; + } + return joinColumn[0].name(); + } + return column[0].name(); + } + + /** + * BFS Walks the Elide model attribute tree and registers all complex attribute types and their sub-types. + * @param elideModel The elide model to scan. + */ + protected void discoverEmbeddedTypeBindings(Type elideModel) { + Queue> toVisit = new ArrayDeque<>(); + Set> visited = new HashSet<>(); + + EntityBinding binding = getEntityBinding(elideModel); + + toVisit.addAll(binding.getAttributes() + .stream() + .filter(this::canBind) + .collect(Collectors.toSet())); + + while (! toVisit.isEmpty()) { + Type next = toVisit.remove(); + + if (visited.contains(next) || entityBindings.containsKey(next)) { + continue; + } + + visited.add(next); + + EntityBinding nextBinding = new EntityBinding(injector, + next, + next.getSimpleName(), + binding.getApiVersion(), + false, + (unused) -> false); + + entityBindings.put(next, nextBinding); + + toVisit.addAll(nextBinding.getAttributes() + .stream() + .filter(this::canBind) + .collect(Collectors.toSet())); + } + } + + /** + * 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(Type modelClass) { + ApiVersion apiVersionAnnotation = + (ApiVersion) getFirstPackageAnnotation(modelClass, Arrays.asList(ApiVersion.class)); + + return (apiVersionAnnotation == null) ? NO_VERSION : apiVersionAnnotation.version(); + } + + private static String getEntityPrefix(Type modelClass) { + Include include = + (Include) getFirstPackageAnnotation(modelClass, Arrays.asList(Include.class)); + + if (include == null || include.name() == null || include.name().isEmpty()) { + return ""; + } + + return include.name() + "_"; + } + + /** + * Looks up the API model name for a given class. + * @param modelClass The model class to lookup. + * @return the API name for the model class. + */ + public static String getEntityName(Type modelClass) { + Type declaringClass = lookupAnnotationDeclarationClass(modelClass, Include.class); + if (declaringClass == null) { + declaringClass = lookupAnnotationDeclarationClass(modelClass, Entity.class); + } + + String entityPrefix = getEntityPrefix(modelClass); + + Preconditions.checkNotNull(declaringClass); + Include include = declaringClass.getAnnotation(Include.class); + + if (include != null && ! "".equals(include.name())) { + return entityPrefix + include.name(); + } + + Entity entity = (Entity) getFirstAnnotation(declaringClass, Arrays.asList(Entity.class)); + if (entity == null || "".equals(entity.name())) { + return entityPrefix + StringUtils.uncapitalize(declaringClass.getSimpleName()); + } + return entityPrefix + entity.name(); + } + + /** + * Looks up the model description for a given class. + * @param modelClass The model class to lookup. + * @return the description for the model class. + */ + public static String getEntityDescription(Type modelClass) { + Include include = (Include) getFirstAnnotation(modelClass, Arrays.asList(Include.class)); + if (include == null || include.description().isEmpty()) { + return null; + } + + return include.description(); + } + + public static Type getType(T object) { + if (object instanceof Dynamic) { + return ((Dynamic) object).getType(); + } else { + ClassType classType = (ClassType) TYPE_MAP.computeIfAbsent( + object.getClass(), + ClassType::new); + return classType; + } + } + + public void bindLegacyHooks(EntityBinding binding) { + binding.getAllMethods().stream() + .map(Method.class::cast) + .forEach(method -> { + if (method.isAnnotationPresent(OnCreatePostCommit.class)) { + bindHookMethod(binding, method, method.getAnnotation(OnCreatePostCommit.class).value(), + TransactionPhase.POSTCOMMIT, Operation.CREATE); + } + if (method.isAnnotationPresent(OnCreatePreCommit.class)) { + bindHookMethod(binding, method, method.getAnnotation(OnCreatePreCommit.class).value(), + TransactionPhase.PRECOMMIT, Operation.CREATE); + } + if (method.isAnnotationPresent(OnCreatePreSecurity.class)) { + bindHookMethod(binding, method, method.getAnnotation(OnCreatePreSecurity.class).value(), + TransactionPhase.PRESECURITY, Operation.CREATE); + } + if (method.isAnnotationPresent(OnUpdatePostCommit.class)) { + bindHookMethod(binding, method, method.getAnnotation(OnUpdatePostCommit.class).value(), + TransactionPhase.POSTCOMMIT, Operation.UPDATE); + } + if (method.isAnnotationPresent(OnUpdatePreCommit.class)) { + bindHookMethod(binding, method, method.getAnnotation(OnUpdatePreCommit.class).value(), + TransactionPhase.PRECOMMIT, Operation.UPDATE); + } + if (method.isAnnotationPresent(OnUpdatePreSecurity.class)) { + bindHookMethod(binding, method, method.getAnnotation(OnUpdatePreSecurity.class).value(), + TransactionPhase.PRESECURITY, Operation.UPDATE); + } + if (method.isAnnotationPresent(OnDeletePostCommit.class)) { + bindHookMethod(binding, method, null, TransactionPhase.POSTCOMMIT, Operation.DELETE); + } + if (method.isAnnotationPresent(OnDeletePreCommit.class)) { + bindHookMethod(binding, method, null, TransactionPhase.PRECOMMIT, Operation.DELETE); + } + if (method.isAnnotationPresent(OnDeletePreSecurity.class)) { + bindHookMethod(binding, method, null, TransactionPhase.PRESECURITY, Operation.DELETE); + } + }); + } + + /** + * Returns whether or not a given model attribute is a complex (not primitive or String) type. + * @param clazz The elide model type. + * @param fieldName The attribute name. + * @return true if the attribute is 'complex'. + */ + public boolean isComplexAttribute(Type clazz, String fieldName) { + EntityBinding binding = getEntityBinding(clazz); + + if (! binding.apiAttributes.contains(fieldName)) { + return false; + } + + Type attributeType = getType(clazz, fieldName); + + return canBind(attributeType); + } + + private void bindHookMethod( + EntityBinding binding, + Method method, + String annotationField, + TransactionPhase phase, + Operation operation) { + + if (StringUtils.isEmpty(annotationField)) { + bindTrigger(binding.entityClass, operation, phase, generateHook(method), false); + } else if (annotationField.equals(ALL_FIELDS)) { + bindTrigger(binding.entityClass, operation, phase, generateHook(method), true); + } else { + bindTrigger(binding.entityClass, annotationField, operation, phase, generateHook(method)); + } + } + + private static LifeCycleHook generateHook(Method method) { + return (operation, phase, model, scope, changes) -> { + try { + int paramCount = method.getParameterCount(); + Class[] paramTypes = method.getParameterTypes(); + + if (changes.isPresent() && paramCount == 2 + && paramTypes[0].isInstance(scope) + && paramTypes[1].isInstance(changes.get())) { + method.invoke(model, scope, changes.get()); + } else if (paramCount == 1 && paramTypes[0].isInstance(scope)) { + method.invoke(model, scope); + } else if (paramCount == 0) { + method.invoke(model); + } else { + throw new IllegalArgumentException(); + } + } catch (ReflectiveOperationException e) { + Throwables.propagateIfPossible(e.getCause()); + throw new IllegalArgumentException(e); + } + }; + } + + private boolean canBind(Type type) { + if (! type.getUnderlyingClass().isPresent()) { + return false; + } + + Class clazz = type.getUnderlyingClass().get(); + + boolean hasNoArgConstructor = + Arrays.stream(clazz.getConstructors()).anyMatch(constructor -> constructor.getParameterCount() == 0); + + //We don't bind primitives. + if (ClassUtils.isPrimitiveOrWrapper(clazz) + || clazz.equals(String.class) + || clazz.isEnum() + + //We don't bind collections. + || Collection.class.isAssignableFrom(clazz) + || Map.class.isAssignableFrom(clazz) + + //We can't bind an attribute type if Elide can't create it... + || ! hasNoArgConstructor + + //We don't bind Elide models as attributes. + || lookupIncludeClass(type) != null + + //If there is a Serde, we assume the type is opaque to Elide.... + || serdeLookup.apply(clazz) != null) { + return false; + } + + return true; + } + + public static class EntityDictionaryBuilder { + public EntityDictionary build() { + + if (scanner == null) { + scanner = DefaultClassScanner.getInstance(); + } + + if (roleChecks == null) { + roleChecks = Collections.emptyMap(); + } + + if (checks == null) { + checks = Collections.emptyMap(); + } + + if (serdeLookup == null) { + serdeLookup = CoerceUtil::lookup; + } + + if (injector == null) { + injector = DEFAULT_INJECTOR; + } + + if (entitiesToExclude == null) { + entitiesToExclude = Collections.emptySet(); + } + + return new EntityDictionary( + checks, + roleChecks, + injector, + serdeLookup, + entitiesToExclude, + scanner + ); + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityPermissions.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityPermissions.java new file mode 100644 index 0000000000..14afa05515 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityPermissions.java @@ -0,0 +1,164 @@ +/* + * 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.dictionary; + +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.UpdatePermission; +import com.yahoo.elide.core.type.AccessibleObject; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.generated.parsers.ExpressionLexer; +import com.yahoo.elide.generated.parsers.ExpressionParser; +import org.antlr.v4.runtime.BailErrorStrategy; +import org.antlr.v4.runtime.BaseErrorListener; +import org.antlr.v4.runtime.CharStream; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.RecognitionException; +import org.antlr.v4.runtime.Recognizer; +import org.antlr.v4.runtime.misc.ParseCancellationException; +import org.antlr.v4.runtime.tree.ParseTree; +import lombok.extern.slf4j.Slf4j; + +import java.lang.annotation.Annotation; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Extract permissions related annotation data for a model. + */ +@Slf4j +public class EntityPermissions { + private static final List> PERMISSION_ANNOTATIONS = Arrays.asList( + ReadPermission.class, + CreatePermission.class, + DeletePermission.class, + NonTransferable.class, + UpdatePermission.class + ); + + public static final EntityPermissions EMPTY_PERMISSIONS = new EntityPermissions(); + + private static final AnnotationBinding EMPTY_BINDING = new AnnotationBinding(null, Collections.emptyMap()); + private final HashMap, AnnotationBinding> bindings = new HashMap<>(); + + private static class AnnotationBinding { + final ParseTree classPermission; + final Map fieldPermissions; + + public AnnotationBinding(ParseTree classPermission, Map fieldPermissions) { + this.classPermission = classPermission; + this.fieldPermissions = fieldPermissions.isEmpty() ? Collections.emptyMap() : fieldPermissions; + } + } + + + private EntityPermissions() { + } + + /** + * Create bindings for entity class to its permission checks. + * @param cls entity class + * @param fieldOrMethodList list of fields/methods + */ + public EntityPermissions(Type cls, + Collection fieldOrMethodList) { + for (Class annotationClass : PERMISSION_ANNOTATIONS) { + final Map fieldPermissions = new HashMap<>(); + fieldOrMethodList.stream() + .forEach(member -> bindMemberPermissions(fieldPermissions, member, annotationClass)); + if (annotationClass != NonTransferable.class) { + ParseTree classPermission = bindClassPermissions(cls, annotationClass); + if (classPermission != null || !fieldPermissions.isEmpty()) { + bindings.put(annotationClass, new AnnotationBinding(classPermission, fieldPermissions)); + } + } + } + } + + private ParseTree bindClassPermissions(Type cls, Class annotationClass) { + Annotation annotation = EntityDictionary.getFirstAnnotation(cls, Arrays.asList(annotationClass)); + return (annotation == null) ? null : getPermissionExpressionTree(annotationClass, annotation); + } + + private void bindMemberPermissions(Map fieldPermissions, + AccessibleObject field, Class annotationClass) { + Annotation annotation = field.getAnnotation(annotationClass); + if (annotation != null) { + ParseTree permissions = getPermissionExpressionTree(annotationClass, annotation); + fieldPermissions.put(EntityBinding.getFieldName(field), permissions); + } + } + + private ParseTree getPermissionExpressionTree(Class annotationClass, Annotation annotation) { + try { + String expression = (String) annotationClass.getMethod("expression").invoke(annotation); + + boolean hasExpression = !expression.isEmpty(); + + if (!hasExpression) { + log.warn("Poorly configured permission: {} {}", annotationClass.getName(), "no checks specified."); + throw new IllegalArgumentException("Poorly configured permission '" + annotationClass.getName() + "'"); + } + + return parseExpression(expression); + } catch (ReflectiveOperationException e) { + log.warn("Unknown permission: {}, {}", annotationClass.getName(), e.toString()); + throw new IllegalArgumentException("Unknown permission '" + annotationClass.getName() + "'", e); + } + } + + public static ParseTree parseExpression(String expression) { + CharStream is = CharStreams.fromString(expression); + ExpressionLexer lexer = new ExpressionLexer(is); + lexer.removeErrorListeners(); + lexer.addErrorListener(new BaseErrorListener() { + @Override + public void syntaxError(Recognizer recognizer, Object offendingSymbol, int line, + int charPositionInLine, String msg, RecognitionException e) { + throw new ParseCancellationException(msg, e); + } + }); + ExpressionParser parser = new ExpressionParser(new CommonTokenStream(lexer)); + parser.setErrorHandler(new BailErrorStrategy()); + lexer.reset(); + return parser.start(); + } + + /** + * Does this permission check annotation exist for this entity or any field? + * @param annotationClass permission class + * @return true if annotation exists + */ + public boolean hasChecksForPermission(Class annotationClass) { + return bindings.containsKey(annotationClass); + } + + /** + * Get entity permission ParseTree. + * @param annotationClass permission class + * @return entity permission ParseTree or null if none + */ + public ParseTree getClassChecksForPermission(Class annotationClass) { + return bindings.getOrDefault(annotationClass, EMPTY_BINDING).classPermission; + } + + /** + * Get field permission ParseTree for provided name. + * @param field provided field name + * @param annotationClass permission class + * @return entity permission ParseTree or null if none + */ + public ParseTree getFieldChecksForPermission(String field, Class annotationClass) { + return bindings.getOrDefault(annotationClass, EMPTY_BINDING).fieldPermissions.get(field); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/Injector.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/Injector.java new file mode 100644 index 0000000000..17d875be0b --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/Injector.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.dictionary; + +import java.lang.reflect.InvocationTargetException; +import java.util.Objects; + +/** + * Abstraction around dependency injection. + */ +@FunctionalInterface +public interface Injector { + + /** + * Inject an elide object. + * + * @param entity object to inject + */ + void inject(Object entity); + + /** + * Instantiates a new instance of a class using the DI framework. + * + * @param cls The class to instantiate. + * @return An instance of the class. + */ + default T instantiate(Class cls) { + try { + return Objects.requireNonNull(cls).getDeclaredConstructor().newInstance(); + } catch (NoSuchMethodException + | InvocationTargetException + | IllegalAccessException + | InstantiationException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/RelationshipType.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/RelationshipType.java similarity index 96% rename from elide-core/src/main/java/com/yahoo/elide/core/RelationshipType.java rename to elide-core/src/main/java/com/yahoo/elide/core/dictionary/RelationshipType.java index b61e3907e9..fc66bed334 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/RelationshipType.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/RelationshipType.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.core; +package com.yahoo.elide.core.dictionary; /** * Relationship types. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/BadRequestException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/BadRequestException.java new file mode 100644 index 0000000000..26cd3a5be8 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/BadRequestException.java @@ -0,0 +1,19 @@ +/* + * 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; + +/** + * Invalid predicate exception. + */ +public class BadRequestException extends HttpStatusException { + public BadRequestException(String message) { + super(HttpStatus.SC_BAD_REQUEST, message); + } + + public BadRequestException(String message, Throwable cause) { + super(HttpStatus.SC_BAD_REQUEST, message, cause, null); + } +} 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 new file mode 100644 index 0000000000..b451f57fa6 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/CustomErrorException.java @@ -0,0 +1,58 @@ +/* + * Copyright 2018, 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.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.lang3.tuple.Pair; + +import java.util.Objects; + +/** + * Define your business exception extend this. + */ +public class CustomErrorException extends HttpStatusException { + private static final long serialVersionUID = 1L; + + private final ErrorObjects errorObjects; + + /** + * constructor. + * @param status http status + * @param message exception message + * @param errorObjects custom error objects, not {@code null} + */ + public CustomErrorException(int status, String message, ErrorObjects errorObjects) { + this(status, message, null, errorObjects); + } + + /** + * constructor. + * @param status http status + * @param message exception message + * @param cause the cause + * @param errorObjects custom error objects, not {@code null} + */ + public CustomErrorException(int status, String message, Throwable cause, ErrorObjects errorObjects) { + super(status, message, cause, null); + this.errorObjects = Objects.requireNonNull(errorObjects, "errorObjects must not be null"); + } + + @Override + public Pair getErrorResponse() { + return buildCustomResponse(); + } + + @Override + public Pair getVerboseErrorResponse() { + return buildCustomResponse(); + } + + private Pair buildCustomResponse() { + // TODO: should support for encoding custom responses be added? + JsonNode responseBody = OBJECT_MAPPER.convertValue(errorObjects, JsonNode.class); + return Pair.of(getStatus(), responseBody); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorMapper.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorMapper.java new file mode 100644 index 0000000000..26ce379915 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorMapper.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021, 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 javax.annotation.Nullable; + +/** + * The ErrorMapper allows mapping any RuntimeException of your choice into more meaningful + * CustomErrorExceptions to improved your error response to the client. + */ +@FunctionalInterface +public interface ErrorMapper { + /** + * @param origin any Exception not caught by default + * @return a mapped CustomErrorException or null if you do not want to map this error + */ + @Nullable CustomErrorException map(Exception origin); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorObjects.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorObjects.java new file mode 100644 index 0000000000..74856c8b30 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorObjects.java @@ -0,0 +1,97 @@ +/* + * Copyright 2018, 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 lombok.Getter; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Error Objects, see http://jsonapi.org/format/#error-objects.

+ * Builder example:
+ *

+ *   ErrorObjects errorObjects = ErrorObjects.builder()
+ *       .addError()
+ *           .withDetail("first error message")
+ *       .addError()
+ *           .withDetail("second error message")
+ *       .build();
+ * 
+ */ +public class ErrorObjects { + @Getter private final List> errors; + + public ErrorObjects(List> errors) { + this.errors = Objects.requireNonNull(errors, "errors must not be null"); + } + + public static ErrorObjectsBuilder builder() { + return new ErrorObjectsBuilder(); + } + + /** + * ErrorObjectsBuilder. + */ + public static class ErrorObjectsBuilder { + private final List> errors; + private Map currentError; + + ErrorObjectsBuilder() { + this.errors = new ArrayList<>(); + } + + public ErrorObjectsBuilder withId(String id) { + return with("id", id); + } + + public ErrorObjectsBuilder withStatus(String status) { + return with("status", status); + } + + public ErrorObjectsBuilder withCode(String code) { + return with("code", code); + } + + public ErrorObjectsBuilder withTitle(String title) { + return with("title", title); + } + + public ErrorObjectsBuilder withDetail(String detail) { + return with("detail", detail); + } + + public ErrorObjectsBuilder with(String key, Object value) { + currentError.put(key, value); + return this; + } + + public ErrorObjectsBuilder addError() { + validateCurrentError(); + Map error = new HashMap<>(); + errors.add(error); + currentError = error; + return this; + } + + public ErrorObjects build() { + if (errors.isEmpty()) { + throw new IllegalArgumentException("At least one error is required"); + } + validateCurrentError(); + return new ErrorObjects(errors); + } + + private void validateCurrentError() throws IllegalArgumentException { + if (currentError != null && currentError.isEmpty()) { + throw new IllegalArgumentException("Error must contain at least one key-value pair"); + } + } + } +} 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 60291a648e..175f6d87d4 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,35 +5,43 @@ */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; -import com.yahoo.elide.security.permissions.expressions.Expression; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.permissions.expressions.Expression; +import com.yahoo.elide.core.type.ClassType; import lombok.Getter; +import java.lang.annotation.Annotation; import java.util.Optional; /** * Access to the requested resource is. * - * {@link com.yahoo.elide.core.HttpStatus#SC_FORBIDDEN forbidden} + * {@link HttpStatus#SC_FORBIDDEN forbidden} */ public class ForbiddenAccessException extends HttpStatusException { private static final long serialVersionUID = 1L; @Getter private final Optional expression; + @Getter private final Optional evaluationMode; - public ForbiddenAccessException(String message) { - super(HttpStatus.SC_FORBIDDEN, null, message); - this.expression = Optional.empty(); + public ForbiddenAccessException(Class permission) { + this(permission, null, null); } - public ForbiddenAccessException(String message, Expression expression) { - super(HttpStatus.SC_FORBIDDEN, null, message); - this.expression = Optional.of(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); } public String getLoggedMessage() { - return String.format("ForbiddenAccessException : Message[%s] Expression [%s]", - getVerboseMessage(), - getExpression()); + return String.format("ForbiddenAccessException: Message=%s\tMode=%s\tExpression=[%s]", + getVerboseMessage(), getEvaluationMode(), getExpression()); + } + + private static String getMessage(Class permission) { + return EntityDictionary.getSimpleName(ClassType.of(permission)) + " Denied"; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/HttpStatus.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatus.java similarity index 80% rename from elide-core/src/main/java/com/yahoo/elide/core/HttpStatus.java rename to elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatus.java index 208cc22ab6..7d94083993 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/HttpStatus.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatus.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.core; +package com.yahoo.elide.core.exceptions; /** * HTTP status codes. @@ -16,6 +16,8 @@ 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_METHOD_NOT_ALLOWED = 405; + 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/exceptions/HttpStatusException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/HttpStatusException.java index 9c84abd75f..3d8709e417 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 @@ -5,14 +5,17 @@ */ package com.yahoo.elide.core.exceptions; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; +import org.owasp.encoder.Encode; +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; /** * Superclass for exceptions that return a Http error status. @@ -22,71 +25,77 @@ public abstract class HttpStatusException extends RuntimeException { private static final long serialVersionUID = 1L; protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - protected final int status; - public int getStatus() { - return status; - } - - private final String verboseMessage; - - public HttpStatusException(int status) { - this(status, null); - } + protected final int status; + private final Optional> verboseMessageSupplier; public HttpStatusException(int status, String message) { - this(status, message, null); - } - - public HttpStatusException(int status, String message, String verboseMessage) { - this(status, message, verboseMessage, null); + this(status, message, (Throwable) null, null); } - public HttpStatusException(int status, String message, String verboseMessage, Throwable cause) { + public HttpStatusException(int status, String message, Throwable cause, Supplier verboseMessageSupplier) { super(message, cause, true, log.isTraceEnabled()); this.status = status; - this.verboseMessage = verboseMessage; + this.verboseMessageSupplier = Optional.ofNullable(verboseMessageSupplier); } protected static String formatExceptionCause(Throwable e) { // if the throwable has a cause use that, otherwise the throwable is the cause - Throwable error = e.getCause() == null - ? e - : e.getCause(); - return error == null - ? null : error.getMessage() == null ? error.toString() - : error.getMessage(); + Throwable error = e.getCause() == null ? e : e.getCause(); + return error != null + ? StringUtils.defaultIfBlank(error.getMessage(), error.toString()) + : null; } + /** + * 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() { - Map> errors = Collections.singletonMap( - "errors", Collections.singletonList(toString()) - ); - 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() { - Map> errors = Collections.singletonMap( - "errors", Collections.singletonList(getVerboseMessage()) - ); - return buildResponse(errors); + return buildResponse(getVerboseMessage()); } - private Pair buildResponse(Map> errors) { + private Pair buildResponse(String message) { + String errorDetail = message; + errorDetail = Encode.forHtml(errorDetail); + + 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 verboseMessage != null - ? verboseMessage - : toString(); + String result = verboseMessageSupplier.map(Supplier::get) + .orElseGet(this::getMessage); + + if (result.equals(this.getMessage())) { + return result; + } else { + + //return both the message and the verbose message. + return this.getMessage() + "\n" + result; + } + } + + public int getStatus() { + return status; } @Override public String toString() { String message = getMessage(); - String className = getClass().getSimpleName(); + String className = EntityDictionary.getSimpleName(ClassType.of(getClass())); if (message == null) { message = className; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InternalServerErrorException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InternalServerErrorException.java index 69e90922a5..80cc8819c7 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InternalServerErrorException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InternalServerErrorException.java @@ -5,12 +5,10 @@ */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; - /** * Requested object ID is. * - * {@link com.yahoo.elide.core.HttpStatus#SC_INTERNAL_SERVER_ERROR invalid} + * {@link HttpStatus#SC_INTERNAL_SERVER_ERROR invalid} */ public class InternalServerErrorException extends HttpStatusException { private static final long serialVersionUID = 1L; @@ -18,7 +16,12 @@ public class InternalServerErrorException extends HttpStatusException { public InternalServerErrorException(String message) { super(HttpStatus.SC_INTERNAL_SERVER_ERROR, message); } - public InternalServerErrorException(Exception e) { - super(HttpStatus.SC_INTERNAL_SERVER_ERROR, e.toString(), null, e); + + public InternalServerErrorException(Throwable e) { + super(HttpStatus.SC_INTERNAL_SERVER_ERROR, e.toString(), e, null); + } + + public InternalServerErrorException(String message, Throwable e) { + super(HttpStatus.SC_INTERNAL_SERVER_ERROR, message, e, null); } } 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 a00da00792..0e4bc5c495 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 @@ -4,19 +4,22 @@ * See LICENSE file in project root for terms. */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; /** * Requested object ID is. * - * {@link com.yahoo.elide.core.HttpStatus#SC_NOT_FOUND invalid} + * {@link HttpStatus#SC_NOT_FOUND invalid} */ 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); + } + public InvalidAttributeException(String attributeName, String type) { - super(HttpStatus.SC_NOT_FOUND, "Unknown attribute '" + attributeName + "' in " + "'" + type + "'"); + this(attributeName, type, null); } public InvalidAttributeException(String message, Throwable cause) { - super(HttpStatus.SC_NOT_FOUND, message, null, cause); + super(HttpStatus.SC_NOT_FOUND, message, cause, null); } } 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..6e19e560df 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 @@ -4,16 +4,15 @@ * See LICENSE file in project root for terms. */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; /** * Requested object ID is. * - * {@link com.yahoo.elide.core.HttpStatus#SC_NOT_FOUND invalid} + * {@link HttpStatus#SC_NOT_FOUND invalid} */ 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/InvalidConstraintException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidConstraintException.java new file mode 100644 index 0000000000..0145fe2920 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidConstraintException.java @@ -0,0 +1,32 @@ +/* + * 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.exceptions; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; + +/** + * Invalid constraint exception. Message is exactly what is provided in the constructor. + * + * {@link HttpStatus#SC_BAD_REQUEST invalid} + */ +public class InvalidConstraintException extends HttpStatusException { + private static final long serialVersionUID = 1L; + + public InvalidConstraintException(String message) { + super(HttpStatus.SC_BAD_REQUEST, message); + } + + @Override + public String toString() { + String message = getMessage(); + + if (message == null) { + return EntityDictionary.getSimpleName(ClassType.of(getClass())); + } + + return message; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidEntityBodyException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidEntityBodyException.java index eb040dda0d..d76b683451 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidEntityBodyException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidEntityBodyException.java @@ -4,12 +4,11 @@ * See LICENSE file in project root for terms. */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; /** * Requested object ID is. * - * {@link com.yahoo.elide.core.HttpStatus#SC_BAD_REQUEST invalid} + * {@link HttpStatus#SC_BAD_REQUEST invalid} */ public class InvalidEntityBodyException extends HttpStatusException { private static final long serialVersionUID = 1L; 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..3de41c34b0 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 @@ -4,17 +4,16 @@ * See LICENSE file in project root for terms. */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; /** * Requested object ID is. * - * {@link com.yahoo.elide.core.HttpStatus#SC_NOT_FOUND invalid} + * {@link HttpStatus#SC_NOT_FOUND invalid} */ 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..fc4af234c6 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 @@ -5,8 +5,6 @@ */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; - /** * Exception representing invalid operations on entities or collections. */ @@ -14,6 +12,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/InvalidOperatorNegationException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperatorNegationException.java new file mode 100644 index 0000000000..7306581347 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperatorNegationException.java @@ -0,0 +1,17 @@ +/* + * 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.exceptions; + +/** + * Invalid predicate negation error. + * + * {@link HttpStatus#SC_INTERNAL_SERVER_ERROR invalid} + */ +public class InvalidOperatorNegationException extends HttpStatusException { + public InvalidOperatorNegationException() { + super(HttpStatus.SC_INTERNAL_SERVER_ERROR, "Invalid usage of NOT in FilterExpression."); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidParameterizedAttributeException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidParameterizedAttributeException.java new file mode 100644 index 0000000000..7de8baefed --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidParameterizedAttributeException.java @@ -0,0 +1,23 @@ +/* + * Copyright 2021, 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.request.Argument; +import com.yahoo.elide.core.request.Attribute; + +/** + * 400 Exception for invalid attribute parameters. + */ +public class InvalidParameterizedAttributeException extends HttpStatusException { + public InvalidParameterizedAttributeException(Attribute attribute) { + super(HttpStatus.SC_BAD_REQUEST, "No attribute found with matching parameters for attribute: " + attribute); + } + + public InvalidParameterizedAttributeException(String attributeName, Argument argument) { + super(HttpStatus.SC_BAD_REQUEST, String.format("Invalid argument : %s for attribute: %s", + argument, attributeName)); + } +} 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 ddbfbb2c20..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidPredicateException.java +++ /dev/null @@ -1,21 +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; - -import com.yahoo.elide.core.HttpStatus; - -/** - * Invalid predicate exception. - */ -public class InvalidPredicateException extends HttpStatusException { - public InvalidPredicateException(String message) { - super(HttpStatus.SC_BAD_REQUEST, message); - } - - public InvalidPredicateException(String message, Throwable cause) { - super(HttpStatus.SC_BAD_REQUEST, message, null, cause); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidURLException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidURLException.java index 65916f9f08..e7e91fbce3 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidURLException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidURLException.java @@ -4,12 +4,11 @@ * See LICENSE file in project root for terms. */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; /** * Requested object ID is. * - * {@link com.yahoo.elide.core.HttpStatus#SC_INTERNAL_SERVER_ERROR invalid} + * {@link HttpStatus#SC_INTERNAL_SERVER_ERROR invalid} */ public class InvalidURLException extends HttpStatusException { private static final long serialVersionUID = 1L; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidValueException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidValueException.java index cbc667bc01..58895a078a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidValueException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidValueException.java @@ -4,24 +4,23 @@ * See LICENSE file in project root for terms. */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; /** * Exception when an invalid value is used. * - * {@link com.yahoo.elide.core.HttpStatus#SC_BAD_REQUEST invalid} + * {@link HttpStatus#SC_BAD_REQUEST invalid} */ public class InvalidValueException extends HttpStatusException { public InvalidValueException(Object value) { - super(HttpStatus.SC_BAD_REQUEST, "Invalid value: " + value.toString()); + this(value, null); } public InvalidValueException(Object value, String verboseMessage) { - super(HttpStatus.SC_BAD_REQUEST, "Invalid value: " + value.toString(), verboseMessage); + super(HttpStatus.SC_BAD_REQUEST, "Invalid value: " + value, null, () -> verboseMessage); } public InvalidValueException(String message, Throwable cause) { - super(HttpStatus.SC_BAD_REQUEST, message, null, cause); + super(HttpStatus.SC_BAD_REQUEST, message, cause, null); } } 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 c5c78aa265..13a776e068 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 @@ -15,16 +15,17 @@ public class JsonPatchExtensionException extends HttpStatusException { private final Pair response; public JsonPatchExtensionException(int status, final JsonNode errorNode) { - super(status); + super(status, ""); response = Pair.of(status, errorNode); } - public JsonPatchExtensionException(final Pair response) { - super(response.getLeft()); - this.response = response; + @Override + public Pair getErrorResponse() { + return response; } - public Pair getResponse() { + @Override + public Pair getVerboseErrorResponse() { 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..7cfb452c64 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/TimeoutException.java @@ -0,0 +1,19 @@ +/* + * 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; + +/** + * 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/TransactionException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/TransactionException.java index a93aff3f13..11a949c40e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/TransactionException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/TransactionException.java @@ -5,17 +5,15 @@ */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; - /** * Requested object ID is. * - * {@link com.yahoo.elide.core.HttpStatus#SC_LOCKED} + * {@link HttpStatus#SC_LOCKED} */ public class TransactionException extends HttpStatusException { private static final long serialVersionUID = 1L; public TransactionException(Throwable e) { - super(HttpStatus.SC_LOCKED, formatExceptionCause(e), null, e); + super(HttpStatus.SC_LOCKED, formatExceptionCause(e), e, null); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/UnableToAddSerdeException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/UnableToAddSerdeException.java new file mode 100644 index 0000000000..e793801410 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/UnableToAddSerdeException.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.core.exceptions; + +public class UnableToAddSerdeException extends RuntimeException { + public UnableToAddSerdeException(String message) { + super(message); + } + + public UnableToAddSerdeException(String message, Throwable cause) { + super(message, cause); + } +} 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..983a191e14 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 @@ -5,13 +5,11 @@ */ package com.yahoo.elide.core.exceptions; -import com.yahoo.elide.core.HttpStatus; - /** * Unknown entity exception. */ 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/FilterOperation.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/FilterOperation.java index 5d1ed0b6fd..803ba966c8 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/FilterOperation.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/FilterOperation.java @@ -5,13 +5,12 @@ */ package com.yahoo.elide.core.filter; -import java.util.Set; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; /** * Interface for filter operations. * @param the return type for apply */ public interface FilterOperation { - T apply(Predicate predicate); - T applyAll(Set predicates); + T apply(FilterPredicate expression); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/InMemoryFilterOperation.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/InMemoryFilterOperation.java index 31923278de..a31c65279e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/InMemoryFilterOperation.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/InMemoryFilterOperation.java @@ -5,39 +5,34 @@ */ package com.yahoo.elide.core.filter; -import com.yahoo.elide.core.EntityDictionary; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; import java.util.Collections; import java.util.Set; -import java.util.stream.Collectors; +import java.util.function.Predicate; /** - * InMemoryFilterOperation + * InMemoryFilterOperation. */ -public class InMemoryFilterOperation implements FilterOperation> { - private final EntityDictionary dictionary; +public class InMemoryFilterOperation implements FilterOperation> { + private final RequestScope requestScope; - public InMemoryFilterOperation(EntityDictionary dictionary) { - this.dictionary = dictionary; + public InMemoryFilterOperation(RequestScope requestScope) { + this.requestScope = requestScope; } @Override - public Set apply(Predicate predicate) { - return Collections.singleton(this.applyOperator(predicate)); + public Set apply(FilterPredicate filterPredicate) { + return Collections.singleton(this.applyOperator(filterPredicate)); } - @Override - public Set applyAll(Set predicates) { - return predicates.stream() - .map(this::applyOperator) - .collect(Collectors.toSet()); - } - - private java.util.function.Predicate applyOperator(Predicate predicate) { - return predicate.apply(dictionary); + private Predicate applyOperator(FilterPredicate filterPredicate) { + return filterPredicate.apply(requestScope); } public EntityDictionary getDictionary() { - return dictionary; + return requestScope.getDictionary(); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/Operator.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/Operator.java index 61ff9fa1f9..d271e58f92 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/Operator.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/Operator.java @@ -5,15 +5,29 @@ */ package com.yahoo.elide.core.filter; -import com.yahoo.elide.core.EntityDictionary; +import static com.yahoo.elide.core.type.ClassType.COLLECTION_TYPE; +import com.yahoo.elide.core.Path; import com.yahoo.elide.core.PersistentResource; -import com.yahoo.elide.core.exceptions.InvalidPredicateException; -import com.yahoo.elide.utils.coerce.CoerceUtil; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.exceptions.InvalidOperatorNegationException; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import org.apache.commons.collections4.CollectionUtils; import lombok.Getter; import lombok.RequiredArgsConstructor; +import java.util.Collection; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiPredicate; +import java.util.function.IntPredicate; import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Operator enum for predicates. @@ -22,94 +36,197 @@ public enum Operator { IN("in", true) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.in(field, values, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return in(fieldPath, values, requestScope); + } + }, + + IN_INSENSITIVE("ini", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return in(fieldPath, values, requestScope, FOLD_CASE); } }, NOT("not", true) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.notIn(field, values, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return (T entity) -> !in(fieldPath, values, requestScope).test(entity); + } + }, + + NOT_INSENSITIVE("noti", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return (T entity) -> !in(fieldPath, values, requestScope, FOLD_CASE).test(entity); + } + }, + + PREFIX_CASE_INSENSITIVE("prefixi", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return prefix(fieldPath, values, requestScope, s -> s.toLowerCase(Locale.ENGLISH)); } }, PREFIX("prefix", true) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.prefix(field, values, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return prefix(fieldPath, values, requestScope, UnaryOperator.identity()); } }, POSTFIX("postfix", true) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.postfix(field, values, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return postfix(fieldPath, values, requestScope, UnaryOperator.identity()); + } + }, + + POSTFIX_CASE_INSENSITIVE("postfixi", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return postfix(fieldPath, values, requestScope, FOLD_CASE); } }, INFIX("infix", true) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.infix(field, values, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return infix(fieldPath, values, requestScope, UnaryOperator.identity()); + } + }, + + INFIX_CASE_INSENSITIVE("infixi", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return infix(fieldPath, values, requestScope, FOLD_CASE); } }, ISNULL("isnull", false) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.isNull(field, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return isNull(fieldPath, requestScope); } }, NOTNULL("notnull", false) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.isNotNull(field, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return (val) -> !isNull(fieldPath, requestScope).test(val); } }, LT("lt", true) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.lt(field, values, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return lt(fieldPath, values, requestScope); } }, LE("le", true) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.le(field, values, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return le(fieldPath, values, requestScope); } }, GT("gt", true) { @Override public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.gt(field, values, dictionary); + Path fieldPath, List values, RequestScope requestScope) { + return gt(fieldPath, values, requestScope); } }, GE("ge", true) { @Override - public Predicate contextualize( - String field, List values, EntityDictionary dictionary) { - return Operator.ge(field, values, dictionary); + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return ge(fieldPath, values, requestScope); + } + }, + + TRUE("true", false) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return isTrue(); + } + }, + + FALSE("false", false) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return isFalse(); + } + }, + + ISEMPTY("isempty", false) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return isEmpty(fieldPath, requestScope); + } + }, + + NOTEMPTY("notempty", false) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return (entity) -> !isEmpty(fieldPath, requestScope).test(entity); + } + }, + + HASMEMBER("hasmember", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return hasMember(fieldPath, values, requestScope); + } + }, + + HASNOMEMBER("hasnomember", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return entity -> !hasMember(fieldPath, values, requestScope).test(entity); + } + }, + BETWEEN("between", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return entity -> between(fieldPath, values, requestScope).test(entity); + } + }, + NOTBETWEEN("notbetween", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return entity -> !between(fieldPath, values, requestScope).test(entity); } }; + public static final UnaryOperator FOLD_CASE = s -> s.toLowerCase(Locale.ENGLISH); @Getter private final String notation; @Getter private final boolean parameterized; + private Operator negated; + + // initialize negated values + static { + GE.negated = LT; + GT.negated = LE; + LE.negated = GT; + LT.negated = GE; + IN.negated = NOT; + IN_INSENSITIVE.negated = NOT_INSENSITIVE; + NOT.negated = IN; + NOT_INSENSITIVE.negated = IN_INSENSITIVE; + TRUE.negated = FALSE; + FALSE.negated = TRUE; + ISNULL.negated = NOTNULL; + NOTNULL.negated = ISNULL; + ISEMPTY.negated = NOTEMPTY; + NOTEMPTY.negated = ISEMPTY; + HASMEMBER.negated = HASNOMEMBER; + HASNOMEMBER.negated = HASMEMBER; + BETWEEN.negated = NOTBETWEEN; + NOTBETWEEN.negated = BETWEEN; + } /** * Returns Operator from query parameter operator notation. @@ -124,155 +241,279 @@ public static Operator fromString(final String string) { } } - throw new InvalidPredicateException("Unknown operator in filter: " + string); + throw new BadRequestException("Unknown operator in filter: " + string); } - public abstract Predicate contextualize( - String field, List values, EntityDictionary dictionary); + public abstract Predicate contextualize(Path fieldPath, List values, RequestScope requestScope); // // Predicate generation // - private static Predicate in( - String field, List values, EntityDictionary dictionary) { + // + // In with strict equality + private static Predicate in(Path fieldPath, List values, RequestScope requestScope) { return (T entity) -> { - Object val = PersistentResource.getValue(entity, field, dictionary); + BiPredicate predicate = (a, b) -> a.equals(b); - return val != null && values.stream() - .map(v -> CoerceUtil.coerce(v, val.getClass())) - .anyMatch(val::equals); + return evaluate(entity, fieldPath, values, predicate, requestScope); }; } - private static Predicate notIn(String field, List values, EntityDictionary dictionary) { + // + // String-like In with optional transformation + private static Predicate in(Path fieldPath, List values, + RequestScope requestScope, UnaryOperator transform) { return (T entity) -> { - Object val = PersistentResource.getValue(entity, field, dictionary); - return val == null || values.stream() - .map(v -> CoerceUtil.coerce(v, val.getClass())) - .noneMatch(val::equals); + BiPredicate predicate = (a, b) -> { + if (!a.getClass().isAssignableFrom(String.class)) { + throw new IllegalStateException("Cannot case insensitive compare non-string values"); + } + + String lhs = transform.apply((String) a); + String rhs = transform.apply(CoerceUtil.coerce(b, String.class)); + + return lhs.equals(rhs); + }; + + return evaluate(entity, fieldPath, values, predicate, requestScope); }; } - private static Predicate prefix( - String field, List values, EntityDictionary dictionary) { + // + // String-like prefix matching with optional transformation + private static Predicate prefix(Path fieldPath, List values, + RequestScope requestScope, UnaryOperator transform) { return (T entity) -> { if (values.size() != 1) { - throw new InvalidPredicateException("PREFIX can only take one argument"); + throw new BadRequestException("PREFIX can only take one argument"); } - Object val = PersistentResource.getValue(entity, field, dictionary); - String valStr = CoerceUtil.coerce(val, String.class); - String filterStr = CoerceUtil.coerce(values.get(0), String.class); + BiPredicate predicate = (a, b) -> { + String lhs = transform.apply(CoerceUtil.coerce(a, String.class)); + String rhs = transform.apply(CoerceUtil.coerce(b, String.class)); + + return lhs != null && rhs != null && lhs.startsWith(rhs); + }; - return valStr != null - && filterStr != null - && valStr.startsWith(filterStr); + return evaluate(entity, fieldPath, values, predicate, requestScope); }; } - private static Predicate postfix( - String field, List values, EntityDictionary dictionary) { + // + // String-like postfix matching with optional transformation + private static Predicate postfix(Path fieldPath, List values, + RequestScope requestScope, UnaryOperator transform) { return (T entity) -> { if (values.size() != 1) { - throw new InvalidPredicateException("POSTFIX can only take one argument"); + throw new BadRequestException("POSTFIX can only take one argument"); } - Object val = PersistentResource.getValue(entity, field, dictionary); - String valStr = CoerceUtil.coerce(val, String.class); - String filterStr = CoerceUtil.coerce(values.get(0), String.class); + BiPredicate predicate = (a, b) -> { + String lhs = transform.apply(CoerceUtil.coerce(a, String.class)); + String rhs = transform.apply(CoerceUtil.coerce(b, String.class)); + + return lhs != null && rhs != null && lhs.endsWith(rhs); + }; - return valStr != null - && filterStr != null - && valStr.endsWith(filterStr); + return evaluate(entity, fieldPath, values, predicate, requestScope); }; } - private static Predicate infix(String field, List values, EntityDictionary dictionary) { + // + // String-like infix matching with optional transformation + private static Predicate infix(Path fieldPath, List values, + RequestScope requestScope, UnaryOperator transform) { return (T entity) -> { if (values.size() != 1) { - throw new InvalidPredicateException("INFIX can only take one argument"); + throw new BadRequestException("INFIX can only take one argument"); } - Object val = PersistentResource.getValue(entity, field, dictionary); - String valStr = CoerceUtil.coerce(val, String.class); - String filterStr = CoerceUtil.coerce(values.get(0), String.class); + BiPredicate predicate = (a, b) -> { + String lhs = transform.apply(CoerceUtil.coerce(a, String.class)); + String rhs = transform.apply(CoerceUtil.coerce(b, String.class)); + + return lhs != null && rhs != null && lhs.contains(rhs); + }; - return valStr != null - && filterStr != null - && valStr.contains(filterStr); + return evaluate(entity, fieldPath, values, predicate, requestScope); }; } - private static Predicate isNull(String field, EntityDictionary dictionary) { - return (T entity) -> { - Object val = PersistentResource.getValue(entity, field, dictionary); - return val == null; - }; + // + // Null checking + private static Predicate isNull(Path fieldPath, RequestScope requestScope) { + return (T entity) -> getFieldValue(entity, fieldPath, requestScope) == null; } - private static Predicate isNotNull(String field, EntityDictionary dictionary) { - return (T entity) -> { - Object val = PersistentResource.getValue(entity, field, dictionary); + private static Predicate lt(Path fieldPath, List values, RequestScope requestScope) { + return getComparator(fieldPath, values, requestScope, compareResult -> compareResult < 0); + } - return val != null; - }; + private static Predicate le(Path fieldPath, List values, RequestScope requestScope) { + return getComparator(fieldPath, values, requestScope, compareResult -> compareResult <= 0); + } + + private static Predicate gt(Path fieldPath, List values, RequestScope requestScope) { + return getComparator(fieldPath, values, requestScope, compareResult -> compareResult > 0); } - private static Predicate lt(String field, List values, EntityDictionary dictionary) { + private static Predicate ge(Path fieldPath, List values, RequestScope requestScope) { + return getComparator(fieldPath, values, requestScope, compareResult -> compareResult >= 0); + } + + private static Predicate between(Path fieldPath, List values, RequestScope requestScope) { return (T entity) -> { - if (values.size() != 1) { - throw new InvalidPredicateException("LT can only take one argument"); + if (values.size() != 2) { + throw new BadRequestException("Between operator expects exactly 2 values"); } - Object val = PersistentResource.getValue(entity, field, dictionary); + Object fieldVal = getFieldValue(entity, fieldPath, requestScope); - return val != null - && getComparisonResult(val, values.get(0)) < 0; + if (fieldVal instanceof Collection) { + return false; + } + + return fieldVal != null + && compare(fieldVal, values.get(0)) >= 0 + && compare(fieldVal, values.get(1)) <= 0; }; } - private static Predicate le(String field, List values, EntityDictionary dictionary) { + private static Predicate isTrue() { + return (T entity) -> true; + } + + private static Predicate isFalse() { + return (T entity) -> false; + } + + private static Predicate isEmpty(Path fieldPath, RequestScope requestScope) { return (T entity) -> { - if (values.size() != 1) { - throw new InvalidPredicateException("LE can only take one argument"); + + Object val = getFieldValue(entity, fieldPath, requestScope); + if (val instanceof Collection) { + return ((Collection) val).isEmpty(); + } + if (val instanceof Map) { + return ((Map) val).isEmpty(); } - Object val = PersistentResource.getValue(entity, field, dictionary); - return val != null - && getComparisonResult(val, values.get(0)) <= 0; + return false; }; } - private static Predicate gt(String field, List values, EntityDictionary dictionary) { + private static Predicate hasMember(Path fieldPath, List values, RequestScope requestScope) { return (T entity) -> { if (values.size() != 1) { - throw new InvalidPredicateException("LE can only take one argument"); + throw new BadRequestException("HasMember can only take one argument"); + } + Object val = getFieldValue(entity, fieldPath, requestScope); + Object filterStr = fieldPath.lastElement() + .map(last -> CoerceUtil.coerce(values.get(0), last.getFieldType())) + .orElseGet(() -> CoerceUtil.coerce(values.get(0), String.class)); + + if (val instanceof Collection) { + return ((Collection) val).contains(filterStr); + } + if (val instanceof Map) { + return ((Map) val).containsKey(filterStr); } - Object val = PersistentResource.getValue(entity, field, dictionary); - return val != null - && getComparisonResult(val, values.get(0)) > 0; + return false; }; } - private static Predicate ge(String field, List values, EntityDictionary dictionary) { + /** + * Return value of field/path for given entity. For example this.book.author + * + * @param the type of entity to retrieve a value from + * @param entity Entity bean + * @param fieldPath field value/path + * @param requestScope Request scope + * @return the value of the field + */ + private static Object getFieldValue(T entity, Path fieldPath, RequestScope requestScope) { + Object val = entity; + for (Path.PathElement field : fieldPath.getPathElements()) { + if ("this".equals(field.getFieldName())) { + continue; + } + if (val == null) { + break; + } + if (val instanceof Collection) { + val = ((Collection) val).stream() + .filter(Objects::nonNull) + .map(target -> PersistentResource.getValue(target, field.getFieldName(), requestScope)) + .filter(Objects::nonNull) + .flatMap(result -> { + if (result instanceof Collection) { + return ((Collection) result).stream(); + } + return Stream.of(result); + }) + .collect(Collectors.toSet()); + } else { + val = PersistentResource.getValue(val, field.getFieldName(), requestScope); + } + } + return val; + } + + private static Predicate getComparator(Path fieldPath, List values, + RequestScope requestScope, IntPredicate condition) { return (T entity) -> { - if (values.size() != 1) { - throw new InvalidPredicateException("LE can only take one argument"); + if (CollectionUtils.isEmpty(values)) { + throw new BadRequestException("No value to compare"); + } + Object fieldVal = getFieldValue(entity, fieldPath, requestScope); + + if (fieldVal instanceof Collection) { + return ((Collection) fieldVal).stream() + .anyMatch(fieldValueElement -> + fieldValueElement != null + && values.stream() + .anyMatch(testVal -> condition.test(compare(fieldValueElement, testVal)))); } - Object val = PersistentResource.getValue(entity, field, dictionary); - return val != null - && getComparisonResult(val, values.get(0)) >= 0; + return fieldVal != null + && values.stream() + .anyMatch(testVal -> condition.test(compare(fieldVal, testVal))); }; + + } + + private static int compare(Object fieldValue, Object rawTestValue) { + Object testValue = CoerceUtil.coerce(rawTestValue, fieldValue.getClass()); + Comparable testComp = CoerceUtil.coerce(testValue, Comparable.class); + Comparable fieldComp = CoerceUtil.coerce(fieldValue, Comparable.class); + + return fieldComp.compareTo(testComp); } - private static int getComparisonResult(Object val, Object rawFilterVal) { - Object filterVal = CoerceUtil.coerce(rawFilterVal, val.getClass()); - Comparable filterComp = CoerceUtil.coerce(filterVal, Comparable.class); - Comparable valComp = CoerceUtil.coerce(val, Comparable.class); + private static boolean evaluate(Object entity, Path fieldPath, List values, + BiPredicate predicate, RequestScope requestScope) { + Type valueClass = fieldPath.lastElement().get().getFieldType(); - return valComp.compareTo(filterComp); + Object leftHandSide = getFieldValue(entity, fieldPath, requestScope); + + if (leftHandSide instanceof Collection && !valueClass.isAssignableFrom(COLLECTION_TYPE)) { + return ((Collection) leftHandSide).stream() + .anyMatch(leftHandSideElement -> + values.stream() + .map(value -> CoerceUtil.coerce(value, valueClass)) + .anyMatch(value -> predicate.test(leftHandSideElement, value))); + } + return leftHandSide != null && values.stream() + .map(value -> valueClass == null ? value : CoerceUtil.coerce(value, valueClass)) + .anyMatch(value -> predicate.test(leftHandSide, value)); + } + + public Operator negate() { + if (negated == null) { + throw new InvalidOperatorNegationException(); + } + return negated; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/Predicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/Predicate.java deleted file mode 100644 index c7f22330fe..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/Predicate.java +++ /dev/null @@ -1,90 +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.filter; - -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.filter.expression.Visitor; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.ToString; -import lombok.NonNull; - - -import java.util.Collections; -import java.util.List; -import java.util.function.Function; - -/** - * Predicate class. - */ -@AllArgsConstructor -@EqualsAndHashCode -public class Predicate implements FilterExpression, Function { - - /** - * The path taken through data model associations to - * reference the field in the operator. - * Eg: author.books.publisher.name - */ - @AllArgsConstructor - @ToString - @EqualsAndHashCode - public static class PathElement { - @Getter Class type; - @Getter String typeName; - @Getter Class fieldType; - @Getter String fieldName; - } - - @Getter @NonNull private List path; - @Getter @NonNull private Operator operator; - @Getter @NonNull private List values; - - public Predicate(PathElement pathElement, Operator op, List values) { - this(Collections.singletonList(pathElement), op, values); - } - - public Predicate(PathElement pathElement, Operator op) { - this(Collections.singletonList(pathElement), op, Collections.emptyList()); - } - - public Predicate(List path, Operator op) { - this(path, op, Collections.emptyList()); - } - - public String getField() { - PathElement last = path.get(path.size() - 1); - return last.getFieldName(); - } - - public String getEntityType() { - PathElement last = path.get(path.size() - 1); - return last.getTypeName(); - } - - @Override - public T accept(Visitor visitor) { - return visitor.visitPredicate(this); - } - - @Override - public java.util.function.Predicate apply(EntityDictionary dictionary) { - return operator.contextualize(getField(), values, dictionary); - } - - @Override - public String toString() { - String formattedPath = path.get(0).getTypeName(); - - for (PathElement element : path) { - formattedPath = formattedPath + "." + element.getFieldName(); - } - - return String.format("%s %s %s", formattedPath, operator, values); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/CaseSensitivityStrategy.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/CaseSensitivityStrategy.java new file mode 100644 index 0000000000..3017545340 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/CaseSensitivityStrategy.java @@ -0,0 +1,59 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.filter.dialect; + +import com.yahoo.elide.core.filter.Operator; + +/** + * Defines the behavior how string comparisons should be compared regarding case sensitivity. + */ +public interface CaseSensitivityStrategy { + /** + * Maps a general case operator to an operator respecting case sensitivity behavior. + * + * @param baseOperator the general case operator (case sensitivity not considered) + * @return comparison operator (case sensitivity considered) + */ + Operator mapOperator(Operator baseOperator); + + /** + * The default Elide strategy which implements the FIQL standard. + * Uses lowercase string comparisons. + * (@see + * FIQL specification) + */ + public class FIQLCompliant implements CaseSensitivityStrategy { + public Operator mapOperator(Operator operator) { + switch (operator) { + case IN: + return Operator.IN_INSENSITIVE; + case NOT: + return Operator.NOT_INSENSITIVE; + case INFIX: + return Operator.INFIX_CASE_INSENSITIVE; + case PREFIX: + return Operator.PREFIX_CASE_INSENSITIVE; + case POSTFIX: + return Operator.POSTFIX_CASE_INSENSITIVE; + default: + return operator; + } + } + } + + /** + * The strategy delegates the decision case sensitivity for string comparison to the + * underlying database column collation definition. + *

+ * This strategy can be used if the underlying database has performance issues due to + * missing functional index functionality (i.e. MySQL). + */ + public class UseColumnCollation implements CaseSensitivityStrategy { + public Operator mapOperator(Operator operator) { + return operator; + } + } +} 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 deleted file mode 100644 index 41e8c77fa0..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/DefaultFilterDialect.java +++ /dev/null @@ -1,197 +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.filter.dialect; - -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.filter.Operator; -import com.yahoo.elide.core.filter.Predicate; -import com.yahoo.elide.core.filter.expression.AndFilterExpression; -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.utils.coerce.CoerceUtil; - -import javax.ws.rs.core.MultivaluedMap; -import java.io.File; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * The default filter dialect supported in Elide 1.0 and 2.0. - */ -public class DefaultFilterDialect implements JoinFilterDialect, SubqueryFilterDialect { - private final EntityDictionary dictionary; - - public DefaultFilterDialect(EntityDictionary dictionary) { - this.dictionary = dictionary; - } - - /** - * Coverts the query parameters to a list of predicates that are then conjoined or organized by type. - * @param queryParams - * @return - * @throws ParseException - */ - private List extractPredicates(MultivaluedMap queryParams) throws ParseException { - List predicates = new ArrayList<>(); - - for (MultivaluedMap.Entry> entry : queryParams.entrySet()) { - // Match "filter[.]" OR "filter[.][]" - - String paramName = entry.getKey(); - List paramValues = entry.getValue(); - - Matcher matcher = Pattern.compile("filter\\[([^\\]]+)\\](\\[([^\\]]+)\\])?") - .matcher(paramName); - if (matcher.find()) { - final String[] keyParts = matcher.group(1).split("\\."); - - if (keyParts.length < 2) { - throw new ParseException("Invalid filter format: " + paramName); - } - - final Operator operator = (matcher.group(3) == null) ? Operator.IN - : Operator.fromString(matcher.group(3)); - - List path = getPath(keyParts); - Predicate.PathElement last = path.get(path.size() - 1); - - final List values = new ArrayList<>(); - for (String valueParams : paramValues) { - for (String valueParam : valueParams.split(",")) { - values.add(CoerceUtil.coerce(valueParam, last.getFieldType())); - } - } - - Predicate predicate = new Predicate(path, operator, values); - - predicates.add(predicate); - } else { - throw new ParseException("Invalid filter format: " + paramName); - } - } - - return predicates; - } - - @Override - public FilterExpression parseGlobalExpression( - String path, - MultivaluedMap filterParams) throws ParseException { - List predicates; - predicates = extractPredicates(filterParams); - - /* Extract the first collection in the URL */ - path = Paths.get(path).normalize().toString().replace(File.separatorChar, '/'); - if (path.startsWith("/")) { - path = path.substring(1); - } - - String[] pathComponents = path.split("/"); - String firstPathComponent = ""; - if (pathComponents.length > 0) { - firstPathComponent = pathComponents[0]; - } - - /* Comma separated filter parameters are joined with logical AND. */ - FilterExpression joinedExpression = null; - - for (Predicate predicate : predicates) { - - /* The first type in the predicate must match the first collection in the URL */ - if (! predicate.getPath().get(0).getTypeName().equals(firstPathComponent)) { - throw new ParseException(String.format("Invalid predicate: %s", predicate)); - } - - if (joinedExpression == null) { - joinedExpression = predicate; - } else { - joinedExpression = new AndFilterExpression(joinedExpression, predicate); - } - } - return joinedExpression; - } - - @Override - public Map parseTypedExpression( - String path, - MultivaluedMap filterParams) throws ParseException { - Map expressionMap = new HashMap<>(); - - List predicates = extractPredicates(filterParams); - - for (Predicate predicate : predicates) { - if (predicate.getPath().size() > 1) { - throw new ParseException("Invalid predicate: " + predicate); - } - - String entityType = predicate.getEntityType(); - - if (expressionMap.containsKey(entityType)) { - FilterExpression filterExpression = expressionMap.get(entityType); - expressionMap.put(entityType, new AndFilterExpression(filterExpression, predicate)); - } else { - expressionMap.put(entityType, predicate); - } - } - - return expressionMap; - } - - /** - * Parses [ author, books, publisher, name ] into - * [(author, books), (book, publisher), (publisher, name)] - * @param keyParts [ author, books, publisher, name ] - * @return [(author, books), (book, publisher), (publisher, name)] - * @throws ParseException - */ - private List getPath(final String[] keyParts) throws ParseException { - if (keyParts == null || keyParts.length <= 0) { - throw new ParseException("Invalid filter expression"); - } - - List path = new ArrayList<>(); - - Class[] types = new Class[keyParts.length]; - String type = keyParts[0]; - types[0] = dictionary.getEntityClass(type); - - if (types[0] == null) { - throw new ParseException("Unknown entity in filter: " + type); - } - - /* Extract all the paths for the associations */ - for (int i = 1 ; i < keyParts.length ; ++i) { - final String field = keyParts[i]; - final Class entityClass = types[i - 1]; - final Class fieldType = ("id".equals(field.toLowerCase(Locale.ENGLISH))) - ? dictionary.getIdType(entityClass) - : dictionary.getParameterizedType(entityClass, field); - if (fieldType == null) { - throw new ParseException("Unknown field in filter: " + field); - } - types[i] = fieldType; - } - - - /* Build all the Predicate path elements */ - for (int i = 0 ; i < types.length - 1 ; ++i) { - Class typeClass = types[i]; - String typeName = dictionary.getJsonAliasFor(types[i]); - String fieldName = keyParts[i + 1]; - Class fieldClass = types[i + 1]; - Predicate.PathElement pathElement = new Predicate.PathElement(typeClass, typeName, fieldClass, fieldName); - - path.add(pathElement); - } - - return path; - } -} 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 deleted file mode 100644 index 0d08534908..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/JoinFilterDialect.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.core.filter.dialect; - -import com.yahoo.elide.core.filter.expression.FilterExpression; - -import javax.ws.rs.core.MultivaluedMap; - -/** - * Parses a filter for one or more entity types that results in a join between them. - * For example, the filter (books.title like '%Foo%' AND books.publisher.name = 'Acme Inc.') - * would require a join between 'book' and 'publisher'. The resulting join is filtered by - * both predicates. - * - * This filter dialect is invoked whenever the first entity is loaded from the DataStoreTransaction. - * For example, the above filter on '/books' would be invoked when 'book' is loaded from the DataStoreTransaction. - */ -public interface JoinFilterDialect { - /** - * @param path the URL path - * @param filterParams the subset of query parameters that start with 'filter' - * @return The root of an expression abstract syntax tree parsed from both the path and the query parameters. - * @throws ParseException - */ - public FilterExpression parseGlobalExpression( - String path, - MultivaluedMap filterParams) 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 deleted file mode 100644 index 4cff31447a..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/MultipleFilterDialect.java +++ /dev/null @@ -1,85 +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.filter.dialect; - -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.filter.expression.FilterExpression; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import javax.ws.rs.core.MultivaluedMap; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** - * A filter dialect that supports an ordered list of different dialects. Dialects - * are attempted in sequence. The first dialect that successfully parses a filter expression - * is used. If no dialect succeeds, the error from the last dialect is returned. - */ -@AllArgsConstructor -@Slf4j -public class MultipleFilterDialect implements JoinFilterDialect, SubqueryFilterDialect { - private List joinDialects; - private List subqueryDialects; - - public MultipleFilterDialect(EntityDictionary dictionary) { - DefaultFilterDialect defaultDialect = new DefaultFilterDialect(dictionary); - joinDialects = new ArrayList<>(); - joinDialects.add(defaultDialect); - subqueryDialects = new ArrayList<>(); - subqueryDialects.add(defaultDialect); - } - - @Override - public FilterExpression parseGlobalExpression(String path, - MultivaluedMap queryParams) throws ParseException { - if (joinDialects.isEmpty()) { - throw new ParseException("Heterogeneous type filtering not supported"); - } - - return parseExpression(joinDialects, (dialect) -> dialect.parseGlobalExpression(path, queryParams)); - } - - @Override - public Map parseTypedExpression(String path, - MultivaluedMap queryParams) - throws ParseException { - - if (subqueryDialects.isEmpty()) { - throw new ParseException("Type filtering not supported"); - } - - return parseExpression(subqueryDialects, (dialect) -> dialect.parseTypedExpression(path, queryParams)); - } - - private static R parseExpression(List dialects, ParseFunction parseFunction) throws ParseException { - ParseException lastFailure = null; - for (T dialect : dialects) { - try { - return parseFunction.apply(dialect); - } catch (ParseException e) { - log.trace("Parse Failure: {}", e.getMessage()); - if (lastFailure != null) { - lastFailure = new ParseException(e.getMessage() + "\n" + lastFailure.getMessage()); - } else { - lastFailure = e; - } - } - } - throw lastFailure; - } - - /** - * A dialect parse function - * @param The parser dialect - * @param the return type of the parser - */ - @FunctionalInterface - public interface ParseFunction { - R apply(T dialect) 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 4df02f4871..8f4160539f 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 @@ -1,20 +1,43 @@ /* - * Copyright 2016, Yahoo Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.core.filter.dialect; -import com.google.common.collect.ImmutableMap; -import com.yahoo.elide.core.EntityDictionary; +import static com.yahoo.elide.core.dictionary.EntityDictionary.REGULAR_ID_NAME; +import static com.yahoo.elide.core.request.Argument.ARGUMENTS_PATTERN; +import static com.yahoo.elide.core.request.Argument.getArgumentsFromString; +import static com.yahoo.elide.core.type.ClassType.COLLECTION_TYPE; +import static com.yahoo.elide.core.type.ClassType.NUMBER_TYPE; +import static com.yahoo.elide.core.type.ClassType.STRING_TYPE; +import static com.yahoo.elide.core.utils.TypeHelper.isPrimitiveNumberType; +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.dictionary.ArgumentType; +import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.InvalidValueException; import com.yahoo.elide.core.filter.Operator; -import com.yahoo.elide.core.filter.Predicate; +import com.yahoo.elide.core.filter.dialect.graphql.FilterDialect; +import com.yahoo.elide.core.filter.dialect.jsonapi.JoinFilterDialect; +import com.yahoo.elide.core.filter.dialect.jsonapi.SubqueryFilterDialect; import com.yahoo.elide.core.filter.expression.AndFilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.filter.expression.NotFilterExpression; import com.yahoo.elide.core.filter.expression.OrFilterExpression; -import com.yahoo.elide.utils.coerce.CoerceUtil; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.filter.predicates.InInsensitivePredicate; +import com.yahoo.elide.core.filter.predicates.InPredicate; +import com.yahoo.elide.core.filter.predicates.IsEmptyPredicate; +import com.yahoo.elide.core.filter.predicates.IsNullPredicate; +import com.yahoo.elide.core.filter.predicates.NotEmptyPredicate; +import com.yahoo.elide.core.filter.predicates.NotNullPredicate; +import com.yahoo.elide.core.request.Argument; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.jsonapi.parser.JsonApiParser; +import com.google.common.collect.ImmutableMap; +import org.apache.commons.collections4.CollectionUtils; import cz.jirutka.rsql.parser.RSQLParser; import cz.jirutka.rsql.parser.RSQLParserException; import cz.jirutka.rsql.parser.ast.AndNode; @@ -24,73 +47,122 @@ import cz.jirutka.rsql.parser.ast.OrNode; import cz.jirutka.rsql.parser.ast.RSQLOperators; import cz.jirutka.rsql.parser.ast.RSQLVisitor; -import lombok.Getter; +import lombok.Builder; +import lombok.NonNull; -import javax.ws.rs.core.MultivaluedMap; -import java.io.File; -import java.nio.file.Paths; +import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; +import javax.ws.rs.core.MultivaluedMap; /** * FilterDialect which implements support for RSQL filter dialect. */ -public class RSQLFilterDialect implements SubqueryFilterDialect, JoinFilterDialect { - +public class RSQLFilterDialect implements FilterDialect, SubqueryFilterDialect, JoinFilterDialect { + private static final String SINGLE_PARAMETER_ONLY = "There can only be a single filter query parameter"; + private static final String INVALID_QUERY_PARAMETER = "Invalid query parameter: "; private static final Pattern TYPED_FILTER_PATTERN = Pattern.compile("filter\\[([^\\]]+)\\]"); - + // field name followed by zero or more filter arguments + // eg: name, orderDate[grain:month] , title[foo:bar][blah:Encoded+Value] + private static final Pattern FILTER_SELECTOR_PATTERN = Pattern.compile("(\\w+)(" + ARGUMENTS_PATTERN + ")*$"); + private static final ComparisonOperator INI = new ComparisonOperator("=ini=", true); + private static final ComparisonOperator NOT_INI = new ComparisonOperator("=outi=", true); private static final ComparisonOperator ISNULL_OP = new ComparisonOperator("=isnull=", false); + private static final ComparisonOperator ISEMPTY_OP = new ComparisonOperator("=isempty=", false); + private static final ComparisonOperator HASMEMBER_OP = new ComparisonOperator("=hasmember=", false); + private static final ComparisonOperator HASNOMEMBER_OP = new ComparisonOperator("=hasnomember=", false); + private static final ComparisonOperator BETWEEN_OP = new ComparisonOperator("=between=", true); + private static final ComparisonOperator NOTBETWEEN_OP = new ComparisonOperator("=notbetween=", true); /* Subset of operators that map directly to Elide operators */ private static final Map OPERATOR_MAP = ImmutableMap.builder() - .put(RSQLOperators.IN, Operator.IN) - .put(RSQLOperators.NOT_IN, Operator.NOT) .put(RSQLOperators.LESS_THAN, Operator.LT) .put(RSQLOperators.GREATER_THAN, Operator.GT) .put(RSQLOperators.GREATER_THAN_OR_EQUAL, Operator.GE) .put(RSQLOperators.LESS_THAN_OR_EQUAL, Operator.LE) + .put(HASMEMBER_OP, Operator.HASMEMBER) + .put(HASNOMEMBER_OP, Operator.HASNOMEMBER) + .put(BETWEEN_OP, Operator.BETWEEN) + .put(NOTBETWEEN_OP, Operator.NOTBETWEEN) .build(); + private final RSQLParser parser; + + @NonNull private final EntityDictionary dictionary; + private final CaseSensitivityStrategy caseSensitivityStrategy; + private final Boolean addDefaultArguments; - public RSQLFilterDialect(EntityDictionary dictionary) { + @Builder + public RSQLFilterDialect(EntityDictionary dictionary, + CaseSensitivityStrategy caseSensitivityStrategy, + Boolean addDefaultArguments) { parser = new RSQLParser(getDefaultOperatorsWithIsnull()); - this. dictionary = dictionary; + this.dictionary = dictionary; + if (caseSensitivityStrategy == null) { + this.caseSensitivityStrategy = new CaseSensitivityStrategy.UseColumnCollation(); + } else { + this.caseSensitivityStrategy = caseSensitivityStrategy; + } + + if (addDefaultArguments == null) { + this.addDefaultArguments = true; + } else { + this.addDefaultArguments = addDefaultArguments; + } } //add rsql isnull op to the default ops - private static Set getDefaultOperatorsWithIsnull() { + public static final Set getDefaultOperatorsWithIsnull() { Set operators = RSQLOperators.defaultOperators(); + operators.add(INI); + operators.add(NOT_INI); operators.add(ISNULL_OP); + operators.add(ISEMPTY_OP); + operators.add(HASMEMBER_OP); + operators.add(HASNOMEMBER_OP); + operators.add(BETWEEN_OP); + operators.add(NOTBETWEEN_OP); return operators; } @Override - public FilterExpression parseGlobalExpression(String path, MultivaluedMap filterParams) + public FilterExpression parse(Type entityClass, + Set attributes, + String filterText, + String apiVersion) + throws ParseException { + return parseFilterExpression(filterText, entityClass, true, true, attributes); + } + + @Override + public FilterExpression parseGlobalExpression(String path, MultivaluedMap filterParams, + String apiVersion) throws ParseException { if (filterParams.size() != 1) { - throw new ParseException("There can only be a single filter query parameter"); + throw new ParseException(SINGLE_PARAMETER_ONLY); } - MultivaluedMap.Entry> entry = filterParams.entrySet().iterator().next(); + MultivaluedMap.Entry> entry = CollectionUtils.get(filterParams, 0); String queryParamName = entry.getKey(); - if (!queryParamName.equals("filter")) { - throw new ParseException("Invalid query parameter: " + queryParamName); + if (!"filter".equals(queryParamName)) { + throw new ParseException(INVALID_QUERY_PARAMETER + queryParamName); } List queryParamValues = entry.getValue(); if (queryParamValues.size() != 1) { - throw new ParseException("There can only be a single filter query parameter"); + throw new ParseException(SINGLE_PARAMETER_ONLY); } String queryParamValue = queryParamValues.get(0); @@ -98,40 +170,27 @@ public FilterExpression parseGlobalExpression(String path, MultivaluedMap 0) { - lastPathComponent = pathComponents[pathComponents.length - 1]; - } + String normalizedPath = JsonApiParser.normalizePath(path); + String[] pathComponents = normalizedPath.split("/"); + String lastPathComponent = pathComponents.length > 0 ? pathComponents[pathComponents.length - 1] : ""; /* * TODO - create a visitor which extracts the type/class of the last path component. * This works today by virtue that global filter expressions are only used for root collections * and NOT nested associations. */ - Class entityType = dictionary.getEntityClass(lastPathComponent); + Type entityType = dictionary.getEntityClass(lastPathComponent, apiVersion); if (entityType == null) { throw new ParseException("No such collection: " + lastPathComponent); } - try { - Node ast = parser.parse(queryParamValue); - RSQL2ExpressionFilterVisitor visitor = new RSQL2ExpressionFilterVisitor(true); - FilterExpression filterExpression = ast.accept(visitor, entityType); - return filterExpression; - } catch (RSQLParserException e) { - throw new ParseException(e.getMessage()); - } + return parseFilterExpression(queryParamValue, entityType, true); } @Override - public Map parseTypedExpression(String path, MultivaluedMap - filterParams) throws ParseException { + public Map parseTypedExpression(String path, MultivaluedMap filterParams, + String apiVersion) + throws ParseException { Map expressionByType = new HashMap<>(); @@ -147,86 +206,198 @@ public Map parseTypedExpression(String path, Multivalu throw new ParseException("Exactly one RSQL expression must be defined for type : " + typeName); } - String expressionText = paramValues.get(0); - - Class entityType = dictionary.getEntityClass(typeName); - + Type entityType = dictionary.getEntityClass(typeName, apiVersion); if (entityType == null) { - throw new ParseException("Invalid query parameter: " + paramName); + throw new ParseException(INVALID_QUERY_PARAMETER + paramName); } - try { - Node ast = parser.parse(expressionText); - RSQL2ExpressionFilterVisitor visitor = new RSQL2ExpressionFilterVisitor(false); - FilterExpression filterExpression = ast.accept(visitor, entityType); - expressionByType.put(typeName, filterExpression); - } catch (RSQLParserException e) { - throw new ParseException(e.getMessage()); - } + String expressionText = paramValues.get(0); + + FilterExpression filterExpression = parseFilterExpression(expressionText, entityType, true); + expressionByType.put(typeName, filterExpression); } else { - throw new ParseException("Invalid query parameter: " + paramName); + throw new ParseException(INVALID_QUERY_PARAMETER + paramName); } } return expressionByType; } + /** + * Parses a RSQL string into an Elide FilterExpression. + * @param expressionText the RSQL string + * @param entityType The type associated with the predicate + * @param allowNestedToManyAssociations Whether or not to reject nested filter paths. + * @return An elide FilterExpression abstract syntax tree + * @throws ParseException + */ + public FilterExpression parseFilterExpression(String expressionText, + Type entityType, + boolean allowNestedToManyAssociations) throws ParseException { + return parseFilterExpression(expressionText, entityType, true, allowNestedToManyAssociations); + } + + /** + * Parses a RSQL string into an Elide FilterExpression. + * @param expressionText the RSQL string + * @param entityType The type associated with the predicate + * @param coerceValues Convert values into their underlying type. + * @param allowNestedToManyAssociations Whether or not to reject nested filter paths. + * @return An elide FilterExpression abstract syntax tree + * @throws ParseException + */ + public FilterExpression parseFilterExpression(String expressionText, + Type entityType, + boolean coerceValues, + boolean allowNestedToManyAssociations) throws ParseException { + return parseFilterExpression(expressionText, entityType, coerceValues, + allowNestedToManyAssociations, Collections.emptySet()); + + } + + /** + * Parses a RSQL string into an Elide FilterExpression. + * @param expressionText the RSQL string + * @param entityType The type associated with the predicate + * @param coerceValues Convert values into their underlying type. + * @param allowNestedToManyAssociations Whether or not to reject nested filter paths. + * @param attributes the set of model attributes being requested. + * @return An elide FilterExpression abstract syntax tree + * @throws ParseException + */ + public FilterExpression parseFilterExpression(String expressionText, + Type entityType, + boolean coerceValues, + boolean allowNestedToManyAssociations, + Set attributes) throws ParseException { + try { + Node ast = parser.parse(expressionText); + RSQL2FilterExpressionVisitor visitor = new RSQL2FilterExpressionVisitor(allowNestedToManyAssociations, + coerceValues, attributes); + return ast.accept(visitor, entityType); + } catch (RSQLParserException e) { + throw new ParseException(e.getMessage()); + } + } + + /** + * Allows base RSQLParseException to carry a parametrized message. + */ + public static class RSQLParseException extends RSQLParserException { + private String message; + + RSQLParseException(String message) { + super(null); + this.message = message; + } + + @Override + public String getMessage() { + return message; + } + } + /** * Visitor which converts RSQL abstract syntax tree into an Elide filter expression. */ - public class RSQL2ExpressionFilterVisitor implements RSQLVisitor { + public class RSQL2FilterExpressionVisitor implements RSQLVisitor { + private boolean allowNestedToManyAssociations = false; + private boolean coerceValues = true; + private Set attributes; - /** - * Allows base RSQLParseException to carry a parameterized message. - */ - public class RSQLParseException extends RSQLParserException { - String message; - RSQLParseException(String message) { - super(new Throwable() { }); - this.message = message; - } - @Override - public String getMessage() { - return message; - } + public RSQL2FilterExpressionVisitor(boolean allowNestedToManyAssociations) { + this(allowNestedToManyAssociations, true, Collections.emptySet()); } - @Getter - boolean allowNestedAssociations = false; + public RSQL2FilterExpressionVisitor(boolean allowNestedToManyAssociations, + boolean coerceValues, Set attributes) { + this.allowNestedToManyAssociations = allowNestedToManyAssociations; + this.coerceValues = coerceValues; + this.attributes = attributes; + } - public RSQL2ExpressionFilterVisitor(boolean allowNestedAssociations) { - this.allowNestedAssociations = allowNestedAssociations; + private Path buildAttribute(Type rootEntityType, String attributeName) { + Attribute attribute = attributes.stream() + .filter(attr -> attr.getName().equals(attributeName) || attr.getAlias().equals(attributeName)) + .findFirst().orElse(null); + + if (attribute != null) { + return new Path(rootEntityType, dictionary, attribute.getName(), + attribute.getAlias(), attribute.getArguments()); + } + return buildPath(rootEntityType, attributeName); } - public List buildPath(Class rootEntityType, String selector) { + private Path buildPath(Type rootEntityType, String selector) { String[] associationNames = selector.split("\\."); - List path = new ArrayList<>(); - Class entityType = rootEntityType; + List path = new ArrayList<>(); + Type entityType = rootEntityType; for (String associationName : associationNames) { - String typeName = dictionary.getJsonAliasFor(entityType); - Class fieldType = dictionary.getParameterizedType(entityType, associationName); + if (!FILTER_SELECTOR_PATTERN.matcher(associationName).matches()) { + throw new RSQLParseException("Filter expression is not in expected format at: " + associationName); + } + + // if the association name is "id", replaced it with real id field name + // id field name can be "id" or other string, but non-id field can't have name "id". + if (associationName.equals(REGULAR_ID_NAME)) { + associationName = dictionary.getIdFieldName(entityType); + } + + Set arguments; + int argsIndex = associationName.indexOf('['); + if (argsIndex > 0) { + try { + arguments = getArgumentsFromString(associationName.substring(argsIndex)); + } catch (UnsupportedEncodingException | IllegalArgumentException e) { + throw new RSQLParseException( + String.format("Filter expression is not in expected format at: %s. %s", + associationName, e.getMessage())); + } + associationName = associationName.substring(0, argsIndex); + } else { + arguments = new HashSet<>(); + } + + if (addDefaultArguments) { + addDefaultArguments(arguments, dictionary.getAttributeArguments(entityType, associationName)); + } + + String typeName = dictionary.getJsonAliasFor(entityType); + Type fieldType = dictionary.getParameterizedType(entityType, associationName); if (fieldType == null) { throw new RSQLParseException( String.format("No such association %s for type %s", associationName, typeName)); } - Predicate.PathElement pathElement = new Predicate.PathElement( - entityType, - typeName, - fieldType, - associationName); - path.add(pathElement); + path.add(new Path.PathElement(entityType, fieldType, associationName, associationName, arguments)); entityType = fieldType; } - return path; + return new Path(path); + } + + private void addDefaultArguments(Set clientArguments, Set availableArgTypes) { + + Set clientArgNames = clientArguments.stream() + .map(Argument::getName) + .collect(Collectors.toSet()); + + // Check if there is any argument which has default value but not provided by client, then add it. + availableArgTypes.stream() + .filter(argType -> !clientArgNames.contains(argType.getName())) + .filter(argType -> argType.getDefaultValue() != null) + .map(argType -> Argument.builder() + .name(argType.getName()) + .value(argType.getDefaultValue()) + .build()) + .forEach(clientArguments::add); } @Override - public FilterExpression visit(AndNode node, Class entityType) { + public FilterExpression visit(AndNode node, Type entityType) { List children = node.getChildren(); if (children.size() < 2) { @@ -246,7 +417,7 @@ public FilterExpression visit(AndNode node, Class entityType) { } @Override - public FilterExpression visit(OrNode node, Class entityType) { + public FilterExpression visit(OrNode node, Type entityType) { List children = node.getChildren(); if (children.size() < 2) { @@ -266,16 +437,44 @@ public FilterExpression visit(OrNode node, Class entityType) { } @Override - public FilterExpression visit(ComparisonNode node, Class entityType) { + public FilterExpression visit(ComparisonNode node, Type entityType) { ComparisonOperator op = node.getOperator(); String relationship = node.getSelector(); List arguments = node.getArguments(); - List path = buildPath(entityType, relationship); + Path path; + // '[' means it has arguments + // If arguments are passed in filter, it overrides the arguments provided in projection. + if (relationship.contains(".") || relationship.contains("[")) { + path = buildPath(entityType, relationship); + } else { + path = buildAttribute(entityType, relationship); + + } + //handles '=isempty=' op before coerce arguments + // ToMany Association is allowed if the operation is IsEmpty + if (op.equals(ISEMPTY_OP)) { + if (FilterPredicate.toManyInPathExceptLastPathElement(dictionary, path)) { + throw new RSQLParseException( + String.format("Invalid association %s. toMany association has to be the target collection.", + relationship)); + } + return buildIsEmptyOperator(path, arguments); + } + + if (op.equals(HASMEMBER_OP) || op.equals(HASNOMEMBER_OP)) { + if (FilterPredicate.toManyInPath(dictionary, path)) { + if (FilterPredicate.isLastPathElementAssignableFrom(dictionary, path, COLLECTION_TYPE)) { + throw new RSQLParseException("Invalid Path: Last Path Element cannot be a collection type"); + } + } else if (!FilterPredicate.isLastPathElementAssignableFrom(dictionary, path, COLLECTION_TYPE)) { + throw new RSQLParseException("Invalid Path: Last Path Element has to be a collection type"); + } + } - if (path.size() > 1 && !allowNestedAssociations) { - throw new RSQLParseException(String.format("No such association %s", relationship)); + if (FilterPredicate.toManyInPath(dictionary, path) && !allowNestedToManyAssociations) { + throw new RSQLParseException(String.format("Invalid association %s", relationship)); } //handles '=isnull=' op before coerce arguments @@ -283,71 +482,125 @@ public FilterExpression visit(ComparisonNode node, Class entityType) { return buildIsNullOperator(path, arguments); } - Class relationshipType = path.get(0).getFieldType(); + Type relationshipType = path.lastElement() + .map(Path.PathElement::getFieldType) + .orElseThrow(() -> new IllegalStateException("Path must not be empty")); //Coerce arguments to their correct types - List values = arguments - .stream() - .map((argument) -> (Object) CoerceUtil.coerce(argument, relationshipType)) + List values = arguments.stream() + .map(argument -> + isPrimitiveNumberType(relationshipType) || NUMBER_TYPE.isAssignableFrom(relationshipType) + ? argument.replace("*", "") //Support filtering on number types + : argument + ) + .map((argument) -> { + try { + return CoerceUtil.coerce(argument, relationshipType); + } catch (InvalidValueException e) { + if (coerceValues) { + throw e; + } + return argument; + } + }) .collect(Collectors.toList()); - - if (op.equals(RSQLOperators.EQUAL)) { - String argument = arguments.get(0); - if (argument.startsWith("*") && argument.endsWith("*") && argument.length() > 2) { - argument = argument.substring(1, argument.length() - 1); - return new Predicate(path, Operator.INFIX, Collections.singletonList(argument)); - } else if (argument.startsWith("*") && argument.length() > 1) { - argument = argument.substring(1, argument.length()); - return new Predicate(path, Operator.POSTFIX, Collections.singletonList(argument)); - } else if (argument.endsWith("*") && argument.length() > 1) { - argument = argument.substring(0, argument.length() - 1); - return new Predicate(path, Operator.PREFIX, Collections.singletonList(argument)); - } else { - return new Predicate(path, Operator.IN, values); - } - } else if (op.equals(RSQLOperators.NOT_EQUAL)) { - String argument = arguments.get(0); - if (argument.startsWith("*") && argument.endsWith("*")) { - argument = argument.substring(1, argument.length() - 1); - return new NotFilterExpression( - new Predicate(path, Operator.INFIX, Collections.singletonList(argument))); - } else if (argument.startsWith("*")) { - argument = argument.substring(1, argument.length()); - return new NotFilterExpression( - new Predicate(path, Operator.POSTFIX, Collections.singletonList(argument))); - } else if (argument.endsWith("*")) { - argument = argument.substring(0, argument.length() - 1); - return new NotFilterExpression( - new Predicate(path, Operator.PREFIX, Collections.singletonList(argument))); - } else { - return new NotFilterExpression(new Predicate(path, Operator.IN, values)); - } - } else if (OPERATOR_MAP.containsKey(op)) { - return new Predicate(path, OPERATOR_MAP.get(op), values); + if (op.equals(RSQLOperators.EQUAL) || op.equals(RSQLOperators.IN)) { + return equalityExpression(arguments.get(0), path, values, true); + } + if (op.equals(INI)) { + return equalityExpression(arguments.get(0), path, values, false); + } + if (op.equals(RSQLOperators.NOT_EQUAL) || op.equals(RSQLOperators.NOT_IN)) { + return new NotFilterExpression(equalityExpression(arguments.get(0), path, values, true)); + } + if (op.equals(NOT_INI)) { + return new NotFilterExpression(equalityExpression(arguments.get(0), path, values, false)); + } + if (OPERATOR_MAP.containsKey(op)) { + return new FilterPredicate(path, OPERATOR_MAP.get(op), values); } throw new RSQLParseException(String.format("Invalid Operator %s", op.getSymbol())); } + private FilterExpression equalityExpression(String argument, Path path, + List values, boolean caseSensitive) { + boolean startsWith = argument.startsWith("*"); + boolean endsWith = argument.endsWith("*"); + if (startsWith && endsWith && argument.length() > 2) { + String value = argument.substring(1, argument.length() - 1); + Operator op = caseSensitive + ? caseSensitivityStrategy.mapOperator(Operator.INFIX) + : Operator.INFIX_CASE_INSENSITIVE; + return new FilterPredicate(path, op, Collections.singletonList(value)); + } + if (startsWith && argument.length() > 1) { + String value = argument.substring(1, argument.length()); + Operator op = caseSensitive + ? caseSensitivityStrategy.mapOperator(Operator.POSTFIX) + : Operator.POSTFIX_CASE_INSENSITIVE; + return new FilterPredicate(path, op, Collections.singletonList(value)); + } + if (endsWith && argument.length() > 1) { + String value = argument.substring(0, argument.length() - 1); + Operator op = caseSensitive + ? caseSensitivityStrategy.mapOperator(Operator.PREFIX) + : Operator.PREFIX_CASE_INSENSITIVE; + return new FilterPredicate(path, op, Collections.singletonList(value)); + } + + boolean isStringLike = path.lastElement() + .filter(e -> e.getFieldType().isAssignableFrom(STRING_TYPE)) + .isPresent(); + if (isStringLike) { + Operator op = caseSensitive + ? caseSensitivityStrategy.mapOperator(Operator.IN) + : Operator.IN_INSENSITIVE; + return new FilterPredicate(path, op, values); + } + + return caseSensitive + ? new InPredicate(path, values) + : new InInsensitivePredicate(path, values); + } + /** * Returns Predicate for '=isnull=' case depending on its arguments. - * + *

* NOTE: Filter Expression builder specially for '=isnull=' case. * * @return Returns Predicate for '=isnull=' case depending on its arguments. */ - private FilterExpression buildIsNullOperator(List path, List arguments) { - Operator elideOP; + private FilterExpression buildIsNullOperator(Path path, List arguments) { + String arg = arguments.get(0); try { - boolean argBool = (boolean) CoerceUtil.coerce(arguments.get(0), boolean.class); - if (argBool) { - elideOP = Operator.ISNULL; - } else { - elideOP = Operator.NOTNULL; + boolean wantsNull = CoerceUtil.coerce(arg, boolean.class); + if (wantsNull) { + return new IsNullPredicate(path); + } + return new NotNullPredicate(path); + } catch (InvalidValueException ignored) { + throw new RSQLParseException(String.format("Invalid value for operator =isnull= '%s'", arg)); + } + } + + /** + * Returns Predicate for '=isempty=' case depending on its arguments. + *

+ * NOTE: Filter Expression builder specially for '=isempty=' case. + * + * @return + */ + private FilterExpression buildIsEmptyOperator(Path path, List arguments) { + String arg = arguments.get(0); + try { + boolean wantsEmpty = CoerceUtil.coerce(arg, boolean.class); + if (wantsEmpty) { + return new IsEmptyPredicate(path); } - return new Predicate(path, elideOP); - } catch (InvalidValueException e) { - throw new RSQLParseException(String.format("Invalid value for operator =isnull=")); + return new NotEmptyPredicate(path); + } catch (InvalidValueException ignored) { + throw new RSQLParseException(String.format("Invalid value for operator =isempty= '%s'", arg)); } } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/graphql/FilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/graphql/FilterDialect.java new file mode 100644 index 0000000000..e927a415a1 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/graphql/FilterDialect.java @@ -0,0 +1,34 @@ +/* + * 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.filter.dialect.graphql; + +import com.yahoo.elide.core.filter.dialect.ParseException; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.type.Type; + +import java.util.Set; + +/** + * GraphQL Dialect for parsing filter API parameters into Filter Expressions. + */ +public interface FilterDialect { + + /** + * Parses a graphQL collection filter parameter and converts it into a FilterExpression. + * @param entityClass The model type of the collection. + * @param attributes The requested attributes, their aliases, and arguments for the given entity model. + * @param filterText The filter string to parse. + * @param apiVersion The API version. + * @return A filter expression. + * @throws ParseException If the filter text is invalid. + */ + FilterExpression parse(Type entityClass, + Set attributes, + String filterText, + String apiVersion) throws ParseException; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/DefaultFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/DefaultFilterDialect.java new file mode 100644 index 0000000000..efca452648 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/DefaultFilterDialect.java @@ -0,0 +1,251 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.filter.dialect.jsonapi; + +import static com.yahoo.elide.core.type.ClassType.COLLECTION_TYPE; +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.Operator; +import com.yahoo.elide.core.filter.dialect.ParseException; +import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.jsonapi.parser.JsonApiParser; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.ws.rs.core.MultivaluedMap; + +/** + * The default filter dialect supported in Elide 1.0 and 2.0. + */ +public class DefaultFilterDialect implements JoinFilterDialect, SubqueryFilterDialect { + private final EntityDictionary dictionary; + public DefaultFilterDialect(EntityDictionary dictionary) { + this.dictionary = dictionary; + } + + /** + * Converts the query parameters to a list of predicates that are then conjoined or organized by type. + * + * @param queryParams the query params + * @return a list of the predicates from the query params + * @throws ParseException when a filter parameter cannot be parsed + */ + private List extractPredicates(MultivaluedMap queryParams, + String apiVersion) throws ParseException { + List filterPredicates = new ArrayList<>(); + + Pattern pattern = Pattern.compile("filter\\[([^\\]]+)\\](\\[([^\\]]+)\\])?"); + for (MultivaluedMap.Entry> entry : queryParams.entrySet()) { + // Match "filter[.]" OR "filter[.][]" + + String paramName = entry.getKey(); + List paramValues = entry.getValue(); + + Matcher matcher = pattern.matcher(paramName); + if (!matcher.find()) { + throw new ParseException("Invalid filter format: " + paramName); + } + + final String[] keyParts = matcher.group(1).split("\\."); + + if (keyParts.length < 2) { + throw new ParseException("Invalid filter format: " + paramName); + } + + final Operator operator = (matcher.group(3) == null) ? Operator.IN + : Operator.fromString(matcher.group(3)); + + Path path = getPath(keyParts, apiVersion); + List elements = path.getPathElements(); + Path.PathElement last = elements.get(elements.size() - 1); + + final List values = new ArrayList<>(); + if (operator.isParameterized()) { + for (String valueParams : paramValues) { + for (String valueParam : valueParams.split(",")) { + values.add(CoerceUtil.coerce(valueParam, last.getFieldType())); + } + } + } + + FilterPredicate filterPredicate = new FilterPredicate(path, operator, values); + + filterPredicates.add(filterPredicate); + } + + return filterPredicates; + } + + @Override + public FilterExpression parseGlobalExpression(String path, MultivaluedMap filterParams, + String apiVersion) + throws ParseException { + List filterPredicates; + filterPredicates = extractPredicates(filterParams, apiVersion); + + /* Extract the first collection in the URL */ + String normalizedPath = JsonApiParser.normalizePath(path); + String[] pathComponents = normalizedPath.split("/"); + String firstPathComponent = ""; + if (pathComponents.length > 0) { + firstPathComponent = pathComponents[0]; + } + + /* Comma separated filter parameters are joined with logical AND. */ + FilterExpression joinedExpression = null; + + for (FilterPredicate filterPredicate : filterPredicates) { + + Type firstClass = filterPredicate.getPath().getPathElements().get(0).getType(); + + /* The first type in the predicate must match the first collection in the URL */ + if (!dictionary.getJsonAliasFor(firstClass).equals(firstPathComponent)) { + throw new ParseException(String.format("Invalid predicate: %s", filterPredicate)); + } + + if ((filterPredicate.getOperator().equals(Operator.HASMEMBER) + || filterPredicate.getOperator().equals(Operator.HASNOMEMBER)) + && !FilterPredicate.isLastPathElementAssignableFrom( + dictionary, filterPredicate.getPath(), COLLECTION_TYPE)) { + throw new ParseException("Invalid Path: Last Path Element has to be a collection type"); + } + + if (joinedExpression == null) { + joinedExpression = filterPredicate; + } else { + joinedExpression = new AndFilterExpression(joinedExpression, filterPredicate); + } + } + + return joinedExpression; + } + + @Override + public Map parseTypedExpression(String path, MultivaluedMap filterParams, + String apiVersion) + throws ParseException { + Map expressionMap = new HashMap<>(); + List filterPredicates = extractPredicates(filterParams, apiVersion); + + for (FilterPredicate filterPredicate : filterPredicates) { + validateFilterPredicate(filterPredicate); + String entityType = dictionary.getJsonAliasFor(filterPredicate.getEntityType()); + FilterExpression filterExpression = expressionMap.get(entityType); + if (filterExpression != null) { + expressionMap.put(entityType, new AndFilterExpression(filterExpression, filterPredicate)); + } else { + expressionMap.put(entityType, filterPredicate); + } + } + + return expressionMap; + } + + /** + * 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, String apiVersion) throws ParseException { + if (keyParts == null || keyParts.length <= 0) { + throw new ParseException("Invalid filter expression"); + } + + List path = new ArrayList<>(); + + Type[] types = new Type[keyParts.length]; + String type = keyParts[0]; + + types[0] = dictionary.getEntityClass(type, apiVersion); + + if (types[0] == null) { + throw new ParseException("Unknown entity in filter: " + type); + } + + /* Extract all the paths for the associations */ + for (int i = 1; i < keyParts.length; ++i) { + final String field = keyParts[i]; + final Type entityClass = types[i - 1]; + final Type fieldType = ("id".equals(field.toLowerCase(Locale.ENGLISH))) + ? dictionary.getIdType(entityClass) + : dictionary.getParameterizedType(entityClass, field); + if (fieldType == null) { + throw new ParseException("Unknown field in filter: " + field); + } + types[i] = fieldType; + } + + + /* Build all the Predicate path elements */ + for (int i = 0; i < types.length - 1; ++i) { + Type typeClass = types[i]; + String fieldName = keyParts[i + 1]; + Type fieldClass = types[i + 1]; + Path.PathElement pathElement = new Path.PathElement(typeClass, fieldClass, fieldName); + + path.add(pathElement); + } + + return new Path(path); + } + + /** + * Check if the relation type in filter predicate is allowed for an operator. + * Defaults behavior is to prevent filter on toMany relationship. + * @param filterPredicate + * @throws ParseException + */ + private void validateFilterPredicate(FilterPredicate filterPredicate) throws ParseException { + switch (filterPredicate.getOperator()) { + case ISEMPTY: + case NOTEMPTY: + emptyOperatorConditions(filterPredicate); + break; + case HASMEMBER: + case HASNOMEMBER: + memberOfOperatorConditions(filterPredicate); + break; + } + } + + /** + * Check if the predicate has toMany relationship that is not target relationship + * on which the empty check is performed. + * @param filterPredicate + * @throws ParseException + */ + private void emptyOperatorConditions(FilterPredicate filterPredicate) throws ParseException { + if (FilterPredicate.toManyInPathExceptLastPathElement(dictionary, filterPredicate.getPath())) { + throw new ParseException( + "Invalid toMany join. toMany association has to be the target collection." + + filterPredicate); + } + } + + private void memberOfOperatorConditions(FilterPredicate filterPredicate) throws ParseException { + if (FilterPredicate.toManyInPath(dictionary, filterPredicate.getPath())) { + if (FilterPredicate.isLastPathElementAssignableFrom(dictionary, + filterPredicate.getPath(), COLLECTION_TYPE)) { + throw new ParseException("Invalid Path: Last Path Element cannot be a collection type"); + } + } else if (!FilterPredicate.isLastPathElementAssignableFrom(dictionary, filterPredicate.getPath(), + COLLECTION_TYPE)) { + throw new ParseException("Invalid Path: Last Path Element has to be a collection type"); + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/JoinFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/JoinFilterDialect.java new file mode 100644 index 0000000000..e1c6123199 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/JoinFilterDialect.java @@ -0,0 +1,36 @@ +/* + * 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.filter.dialect.jsonapi; + +import com.yahoo.elide.core.filter.dialect.ParseException; +import com.yahoo.elide.core.filter.expression.FilterExpression; + +import javax.ws.rs.core.MultivaluedMap; + +/** + * Parses a filter for one or more entity types that results in a join between them. + * For example, the filter (books.title like '%Foo%' AND books.publisher.name = 'Acme Inc.') + * would require a join between 'book' and 'publisher'. The resulting join is filtered by + * both predicates. + *

+ * This filter dialect is invoked whenever the first entity is loaded from the DataStoreTransaction. + * For example, the above filter on '/books' would be invoked when 'book' is loaded from the DataStoreTransaction. + */ +public interface JoinFilterDialect { + /** + * Join filters must be able to parse global expressions. + * + * @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, + String apiVersion) throws ParseException; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/MultipleFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/MultipleFilterDialect.java new file mode 100644 index 0000000000..c60bca14cd --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/MultipleFilterDialect.java @@ -0,0 +1,98 @@ +/* + * 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.filter.dialect.jsonapi; + +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.dialect.ParseException; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import javax.ws.rs.core.MultivaluedMap; + +/** + * A filter dialect that supports an ordered list of different dialects. Dialects + * are attempted in sequence. The first dialect that successfully parses a filter expression + * is used. If no dialect succeeds, the error from the last dialect is returned. + */ +@AllArgsConstructor +@Slf4j +public class MultipleFilterDialect implements JoinFilterDialect, SubqueryFilterDialect { + private List joinDialects; + private List subqueryDialects; + + public MultipleFilterDialect(EntityDictionary dictionary) { + DefaultFilterDialect defaultDialect = new DefaultFilterDialect(dictionary); + joinDialects = new ArrayList<>(); + joinDialects.add(defaultDialect); + subqueryDialects = new ArrayList<>(); + subqueryDialects.add(defaultDialect); + } + + @Override + public FilterExpression parseGlobalExpression(String path, + 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, apiVersion)); + } + + @Override + public Map parseTypedExpression(String path, + MultivaluedMap queryParams, + String apiVersion) + throws ParseException { + + if (subqueryDialects.isEmpty()) { + throw new ParseException("Type filtering not supported"); + } + + return parseExpression(subqueryDialects, (dialect) -> dialect.parseTypedExpression(path, + queryParams, apiVersion)); + } + + private static R parseExpression(List dialects, ParseFunction parseFunction) throws ParseException { + ParseException lastFailure = null; + for (T dialect : dialects) { + try { + return parseFunction.apply(dialect); + } catch (ParseException e) { + if (log.isTraceEnabled()) { + log.trace("Parse Failure: {}", e.getMessage()); + } + if (lastFailure != null) { + ParseException prev = lastFailure; + lastFailure = new ParseException(e.getMessage() + "\n" + lastFailure.getMessage()); + lastFailure.addSuppressed(prev); + lastFailure.addSuppressed(e); + } else { + lastFailure = e; + } + } + } + if (lastFailure == null) { + lastFailure = new ParseException("No dialects"); + } + throw lastFailure; + } + + /** + * A dialect parse function. + * + * @param The parser dialect + * @param the return type of the parser + */ + @FunctionalInterface + public interface ParseFunction { + R apply(T dialect) throws ParseException; + } +} 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/jsonapi/SubqueryFilterDialect.java similarity index 77% rename from elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/SubqueryFilterDialect.java rename to elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/SubqueryFilterDialect.java index d98b1998a6..ab0757e691 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/jsonapi/SubqueryFilterDialect.java @@ -3,12 +3,13 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.core.filter.dialect; +package com.yahoo.elide.core.filter.dialect.jsonapi; +import com.yahoo.elide.core.filter.dialect.ParseException; import com.yahoo.elide.core.filter.expression.FilterExpression; -import javax.ws.rs.core.MultivaluedMap; import java.util.Map; +import javax.ws.rs.core.MultivaluedMap; /** * Parses filters which are bound to a particular entity type. Whenever a collection of entities @@ -28,10 +29,15 @@ */ public interface SubqueryFilterDialect { /** + * Parse a filter that is scoped to a particular type. + * * @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 8a3b70799d..503bfacb44 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,13 +17,40 @@ 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); + } + if (left == null) { + return right; + } + return left; + } + public AndFilterExpression(FilterExpression left, FilterExpression right) { this.left = left; this.right = right; } @Override - public T accept(Visitor visitor) { + public T accept(FilterExpressionVisitor visitor) { return visitor.visitAndExpression(this); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitor.java new file mode 100644 index 0000000000..84d949123a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitor.java @@ -0,0 +1,45 @@ +/* + * 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.filter.expression; + +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; + +/** + * A Visitor which deep clones an entire filter expression. + */ +public class ExpressionScopingVisitor implements FilterExpressionVisitor { + + PathElement scope; + + public ExpressionScopingVisitor(PathElement scope) { + this.scope = scope; + } + + @Override + public FilterExpression visitPredicate(FilterPredicate filterPredicate) { + return filterPredicate.scopedBy(scope); + } + + @Override + public FilterExpression visitAndExpression(AndFilterExpression expression) { + return new AndFilterExpression( + expression.getLeft().accept(this), + expression.getRight().accept(this)); + } + + @Override + public FilterExpression visitOrExpression(OrFilterExpression expression) { + return new OrFilterExpression( + expression.getLeft().accept(this), + expression.getRight().accept(this)); + } + + @Override + public FilterExpression visitNotExpression(NotFilterExpression expression) { + return new NotFilterExpression(expression.getNegated().accept(this)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterExpression.java index fc7d9aa641..17c2ceb08c 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterExpression.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterExpression.java @@ -9,5 +9,5 @@ * A filter expression. */ public interface FilterExpression { - public T accept(Visitor visitor); + public T accept(FilterExpressionVisitor visitor); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterExpressionVisitor.java new file mode 100644 index 0000000000..3aedf41a1a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterExpressionVisitor.java @@ -0,0 +1,19 @@ +/* + * 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.filter.expression; + +import com.yahoo.elide.core.filter.predicates.FilterPredicate; + +/** + * Visitor which walks the filter expression abstract syntax tree. + * @param The return type of the visitor + */ +public interface FilterExpressionVisitor { + T visitPredicate(FilterPredicate filterPredicate); + T visitAndExpression(AndFilterExpression expression); + T visitOrExpression(OrFilterExpression expression); + T visitNotExpression(NotFilterExpression expression); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterPredicatePushdownExtractor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterPredicatePushdownExtractor.java new file mode 100644 index 0000000000..c424e8ff59 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterPredicatePushdownExtractor.java @@ -0,0 +1,81 @@ +/* + * 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.filter.expression; + +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.filter.visitors.FilterExpressionNormalizationVisitor; + +/** + * Examines a FilterExpression to determine if some or all of it can be pushed to the data store. + */ +public class FilterPredicatePushdownExtractor implements FilterExpressionVisitor { + + private EntityDictionary dictionary; + + public FilterPredicatePushdownExtractor(EntityDictionary dictionary) { + this.dictionary = dictionary; + } + + @Override + public FilterExpression visitPredicate(FilterPredicate filterPredicate) { + + boolean filterInMemory = filterPredicate.getPath().isComputed(dictionary); + return (filterInMemory) ? null : filterPredicate; + } + + @Override + public FilterExpression visitAndExpression(AndFilterExpression expression) { + FilterExpression left = expression.getLeft().accept(this); + FilterExpression right = expression.getRight().accept(this); + + if (left == null) { + return right; + } + + if (right == null) { + return left; + } + + return new AndFilterExpression(left, right); + } + + @Override + public FilterExpression visitOrExpression(OrFilterExpression expression) { + FilterExpression left = expression.getLeft().accept(this); + FilterExpression right = expression.getRight().accept(this); + + if (left == null || right == null) { + return null; + } + return new OrFilterExpression(left, right); + } + + @Override + public FilterExpression visitNotExpression(NotFilterExpression expression) { + FilterExpression inner = expression.getNegated().accept(this); + + if (inner == null) { + return null; + } + + return expression; + } + + /** + * @param dictionary + * @param expression + * @return A filter expression that can be safely executed in the data store. + */ + public static FilterExpression extractPushDownPredicate(EntityDictionary dictionary, FilterExpression expression) { + FilterExpressionNormalizationVisitor normalizationVisitor = new FilterExpressionNormalizationVisitor(); + FilterExpression normalizedExpression = expression.accept(normalizationVisitor); + FilterPredicatePushdownExtractor verifier = new FilterPredicatePushdownExtractor(dictionary); + + return normalizedExpression.accept(verifier); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryExecutionVerifier.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryExecutionVerifier.java new file mode 100644 index 0000000000..8749c59433 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryExecutionVerifier.java @@ -0,0 +1,60 @@ +/* + * 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.filter.expression; + +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; + +/** + * Intended to specify whether the expression must be evaluated in memory or can be pushed to the DataStore. + * Constructs true if any part of the expression relies on a computed attribute or relationship. + * Otherwise constructs false. + */ +public class InMemoryExecutionVerifier implements FilterExpressionVisitor { + private EntityDictionary dictionary; + + public InMemoryExecutionVerifier(EntityDictionary dictionary) { + this.dictionary = dictionary; + } + + @Override + public Boolean visitPredicate(FilterPredicate filterPredicate) { + return filterPredicate.getPath().isComputed(dictionary); + } + + @Override + public Boolean visitAndExpression(AndFilterExpression expression) { + FilterExpression left = expression.getLeft(); + FilterExpression right = expression.getRight(); + // is either computed? + return (left.accept(this) || right.accept(this)); + } + + @Override + public Boolean visitOrExpression(OrFilterExpression expression) { + FilterExpression left = expression.getLeft(); + FilterExpression right = expression.getRight(); + // is either computed? + return (left.accept(this) || right.accept(this)); + } + + @Override + public Boolean visitNotExpression(NotFilterExpression expression) { + return expression.getNegated().accept(this); + } + + /** + * @param dictionary + * @param expression + * @return Returns true if the filter expression must be evaluated in memory. + */ + public static boolean shouldExecuteInMemory(EntityDictionary dictionary, FilterExpression expression) { + InMemoryExecutionVerifier verifier = new InMemoryExecutionVerifier(dictionary); + + return expression.accept(verifier); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryFilterExecutor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryFilterExecutor.java new file mode 100644 index 0000000000..2d1211565e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryFilterExecutor.java @@ -0,0 +1,47 @@ +/* + * 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.filter.expression; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; + +import java.util.function.Predicate; + +/** + * Visitor for in memory filterExpressions. + */ +public class InMemoryFilterExecutor implements FilterExpressionVisitor { + private final RequestScope requestScope; + + public InMemoryFilterExecutor(RequestScope requestScope) { + this.requestScope = requestScope; + } + + @Override + public Predicate visitPredicate(FilterPredicate filterPredicate) { + return filterPredicate.apply(requestScope); + } + + @Override + public Predicate visitAndExpression(AndFilterExpression expression) { + Predicate leftPredicate = expression.getLeft().accept(this); + Predicate rightPredicate = expression.getRight().accept(this); + return t -> leftPredicate.and(rightPredicate).test(t); + } + + @Override + public Predicate visitOrExpression(OrFilterExpression expression) { + Predicate leftPredicate = expression.getLeft().accept(this); + Predicate rightPredicate = expression.getRight().accept(this); + return t -> leftPredicate.or(rightPredicate).test(t); + } + + @Override + public Predicate visitNotExpression(NotFilterExpression expression) { + Predicate predicate = expression.getNegated().accept(this); + return t -> predicate.negate().test(t); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryFilterVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryFilterVisitor.java deleted file mode 100644 index 637c502679..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryFilterVisitor.java +++ /dev/null @@ -1,46 +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.filter.expression; - -import com.yahoo.elide.core.EntityDictionary; - -import java.util.function.Predicate; - -/** - * Visitor for in memory filterExpressions - */ -public class InMemoryFilterVisitor implements Visitor { - private final EntityDictionary dictionary; - - public InMemoryFilterVisitor(EntityDictionary dictionary) { - this.dictionary = dictionary; - } - - @Override - public Predicate visitPredicate(com.yahoo.elide.core.filter.Predicate predicate) { - return predicate.apply(dictionary); - } - - @Override - public Predicate visitAndExpression(AndFilterExpression expression) { - Predicate leftPredicate = expression.getLeft().accept(this); - Predicate rightPredicate = expression.getRight().accept(this); - return t -> leftPredicate.and(rightPredicate).test(t); - } - - @Override - public Predicate visitOrExpression(OrFilterExpression expression) { - Predicate leftPredicate = expression.getLeft().accept(this); - Predicate rightPredicate = expression.getRight().accept(this); - return t -> leftPredicate.or(rightPredicate).test(t); - } - - @Override - public Predicate visitNotExpression(NotFilterExpression expression) { - Predicate predicate = expression.getNegated().accept(this); - return t -> predicate.negate().test(t); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/NotFilterExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/NotFilterExpression.java index de63b97753..6ad1437a53 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/NotFilterExpression.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/NotFilterExpression.java @@ -21,7 +21,7 @@ public NotFilterExpression(FilterExpression negated) { } @Override - public T accept(Visitor visitor) { + public T accept(FilterExpressionVisitor visitor) { return visitor.visitNotExpression(this); } 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 a5ac6e3098..a364e28f79 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,13 +18,40 @@ 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); + } + if (left == null) { + return right; + } + return left; + } + public OrFilterExpression(FilterExpression left, FilterExpression right) { this.left = left; this.right = right; } @Override - public T accept(Visitor visitor) { + public T accept(FilterExpressionVisitor visitor) { return visitor.visitOrExpression(this); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/PredicateExtractionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/PredicateExtractionVisitor.java index 81fa815c7b..7fca61bf47 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/PredicateExtractionVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/PredicateExtractionVisitor.java @@ -5,42 +5,57 @@ */ package com.yahoo.elide.core.filter.expression; -import com.yahoo.elide.core.filter.Predicate; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; import lombok.Getter; -import java.util.HashSet; -import java.util.Set; +import java.util.Collection; +import java.util.LinkedHashSet; /** * A Visitor which extracts the set of predicates from a filter FilterExpression. * Should only be used in Elide 2.0 scope */ -public class PredicateExtractionVisitor implements Visitor> { - @Getter Set predicates = new HashSet<>(); +public class PredicateExtractionVisitor implements FilterExpressionVisitor> { + @Getter Collection filterPredicates; + + /** + * Defaults to extracting a set of predicates. + */ + public PredicateExtractionVisitor() { + filterPredicates = new LinkedHashSet<>(); + } + + /** + * Extracts predicates into the provided collection. + * @param predicates The collection (list, set, etc) to store the predicates in. + */ + public PredicateExtractionVisitor(Collection predicates) { + filterPredicates = predicates; + } @Override - public Set visitPredicate(Predicate predicate) { - predicates.add(predicate); - return predicates; + public Collection visitPredicate(FilterPredicate filterPredicate) { + filterPredicates.add(filterPredicate); + return filterPredicates; } @Override - public Set visitAndExpression(AndFilterExpression expression) { - predicates.addAll(expression.getLeft().accept(this)); - predicates.addAll(expression.getRight().accept(this)); - return predicates; + public Collection visitAndExpression(AndFilterExpression expression) { + expression.getLeft().accept(this); + expression.getRight().accept(this); + return filterPredicates; } @Override - public Set visitOrExpression(OrFilterExpression expression) { - predicates.addAll(expression.getLeft().accept(this)); - predicates.addAll(expression.getRight().accept(this)); - return predicates; + public Collection visitOrExpression(OrFilterExpression expression) { + expression.getLeft().accept(this); + expression.getRight().accept(this); + return filterPredicates; } @Override - public Set visitNotExpression(NotFilterExpression expression) { - predicates.addAll(expression.getNegated().accept(this)); - return predicates; + public Collection visitNotExpression(NotFilterExpression expression) { + expression.getNegated().accept(this); + return filterPredicates; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/Visitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/Visitor.java deleted file mode 100644 index a9da80b792..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/Visitor.java +++ /dev/null @@ -1,19 +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.filter.expression; - -import com.yahoo.elide.core.filter.Predicate; - -/** - * Visitor which walks the filter expression abstract syntax tree. - * @param The return type of the visitor - */ -public interface Visitor { - public T visitPredicate(Predicate predicate); - public T visitAndExpression(AndFilterExpression expression); - public T visitOrExpression(OrFilterExpression expression); - public T visitNotExpression(NotFilterExpression expression); -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/FalsePredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/FalsePredicate.java new file mode 100644 index 0000000000..5f7a1e81fc --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/FalsePredicate.java @@ -0,0 +1,26 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * False Predicate class. + */ +public class FalsePredicate extends FilterPredicate { + + public FalsePredicate(Path path) { + super(path, Operator.FALSE, Collections.emptyList()); + } + + public FalsePredicate(PathElement pathElement) { + this(new Path(Collections.singletonList(pathElement))); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/FilterPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/FilterPredicate.java new file mode 100644 index 0000000000..2f9e12fa6c --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/FilterPredicate.java @@ -0,0 +1,184 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.RelationshipType; +import com.yahoo.elide.core.filter.Operator; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpressionVisitor; +import com.yahoo.elide.core.type.Type; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import org.apache.commons.lang3.StringUtils; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NonNull; + +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * Predicate class. + */ +@EqualsAndHashCode +public class FilterPredicate implements FilterExpression, Function { + private static final String UNDERSCORE = "_"; + private static final String PERIOD = "."; + private static final PathElement[] ELEMENT_ARRAY = new PathElement[0]; + + @Getter @NonNull private Path path; + @Getter @NonNull private Operator operator; + @Getter @NonNull private List values; + @Getter @NonNull private String field; + @Getter @NonNull private String fieldPath; + @Getter @NonNull private Type fieldType; + + public static boolean toManyInPath(EntityDictionary dictionary, Path path) { + return path.getPathElements().stream() + .map(element -> dictionary.getRelationshipType(element.getType(), element.getFieldName())) + .anyMatch(RelationshipType::isToMany); + } + + public static boolean toManyInPathExceptLastPathElement(EntityDictionary dictionary, Path path) { + int pathLength = path.getPathElements().size(); + return path.getPathElements().stream() + .limit(pathLength - 1) + .map(element -> dictionary.getRelationshipType(element.getType(), element.getFieldName())) + .anyMatch(RelationshipType::isToMany); + } + + public static boolean isLastPathElementAssignableFrom(EntityDictionary dictionary, Path path, Type clz) { + return path.lastElement() + .filter(last -> + clz.isAssignableFrom( + dictionary.getType(last.getType(), last.getFieldName()) + )) + .isPresent(); + } + + public FilterPredicate(PathElement pathElement, Operator op, List values) { + this(new Path(Collections.singletonList(pathElement)), op, values); + } + + public FilterPredicate(FilterPredicate copy) { + this(copy.path, copy.operator, copy.values); + } + + public FilterPredicate(Path path, Operator op, List values) { + this.operator = op; + this.path = new Path(path); + this.values = ImmutableList.copyOf(values); + this.field = path.lastElement() + .map(PathElement::getFieldName) + .orElse(null); + this.fieldPath = path.getPathElements().stream() + .map(PathElement::getFieldName) + .collect(Collectors.joining(PERIOD)); + this.fieldType = path.lastElement() + .map(PathElement::getFieldType) + .orElse(null); + } + + /** + * Compute the parameter value/name pairings. + * @return the filter parameters for this predicate + */ + public List getParameters() { + String baseName = String.format("%s_%s_", + getFieldPath().replace(PERIOD, UNDERSCORE), + Integer.toHexString(hashCode())); + return IntStream.range(0, values.size()) + .mapToObj(idx -> new FilterParameter(String.format("%s%d", baseName, idx), values.get(idx))) + .collect(Collectors.toList()); + } + + /** + * Create a copy of this filter that is scoped by scope. This is used in calculating page totals, we need to + * scope this filter in the context of it's parent. + * + * @param scope the path element to add to the head of the path + * @return the scoped filter expression. + */ + public FilterPredicate scopedBy(PathElement scope) { + List pathElements = Lists.asList(scope, path.getPathElements().toArray(ELEMENT_ARRAY)); + return new FilterPredicate(new Path(pathElements), operator, values); + } + + public Type getEntityType() { + List elements = path.getPathElements(); + PathElement first = elements.get(0); + return first.getType(); + } + + @Override + public T accept(FilterExpressionVisitor visitor) { + return visitor.visitPredicate(this); + } + + @Override + public Predicate apply(RequestScope dictionary) { + return operator.contextualize(path, values, dictionary); + } + + public boolean isMatchingOperator() { + return operator == Operator.INFIX + || operator == Operator.INFIX_CASE_INSENSITIVE + || operator == Operator.PREFIX + || operator == Operator.PREFIX_CASE_INSENSITIVE + || operator == Operator.POSTFIX + || operator == Operator.POSTFIX_CASE_INSENSITIVE; + } + + @Override + public String toString() { + List elements = path.getPathElements(); + StringBuilder formattedPath = new StringBuilder(); + if (!elements.isEmpty()) { + formattedPath.append(StringUtils.uncapitalize(EntityDictionary.getSimpleName(elements.get(0).getType()))); + } + + for (PathElement element : elements) { + formattedPath.append(PERIOD).append(element.getFieldName()); + } + + return formattedPath.append(' ').append(operator).append(' ').append(values).toString(); + } + + public FilterPredicate negate() { + Operator newOp = operator.negate(); + return new FilterPredicate(this.path, newOp, this.values); + } + + /** + * A wrapper for filter parameters, for HQL injection. + */ + @AllArgsConstructor + public static class FilterParameter { + @Getter private String name; + @Getter private Object value; + private static final Pattern ESCAPE_PATTERN = Pattern.compile("%", Pattern.LITERAL); + private static final String ESCAPED = Matcher.quoteReplacement("\\%"); + + public String getPlaceholder() { + return ":" + name; + } + + public String escapeMatching() { + return ESCAPE_PATTERN.matcher(value.toString()).replaceAll(ESCAPED); + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/GEPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/GEPredicate.java new file mode 100644 index 0000000000..1db0fe04ba --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/GEPredicate.java @@ -0,0 +1,26 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * GE Predicate class. + */ +public class GEPredicate extends FilterPredicate { + + public GEPredicate(Path path, Object value) { + super(path, Operator.GE, Collections.singletonList(value)); + } + + public GEPredicate(PathElement pathElement, Object value) { + this(new Path(Collections.singletonList(pathElement)), value); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/GTPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/GTPredicate.java new file mode 100644 index 0000000000..e8abd55711 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/GTPredicate.java @@ -0,0 +1,26 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * GT Predicate class. + */ +public class GTPredicate extends FilterPredicate { + + public GTPredicate(Path path, Object value) { + super(path, Operator.GT, Collections.singletonList(value)); + } + + public GTPredicate(PathElement pathElement, Object value) { + this(new Path(Collections.singletonList(pathElement)), value); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InInsensitivePredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InInsensitivePredicate.java new file mode 100644 index 0000000000..76ce69a6b1 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InInsensitivePredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * IN Insensitive Predicate class. + */ +public class InInsensitivePredicate extends FilterPredicate { + + public InInsensitivePredicate(Path path, List values) { + super(path, Operator.IN_INSENSITIVE, values); + } + + @SafeVarargs + public InInsensitivePredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public InInsensitivePredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public InInsensitivePredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InPredicate.java new file mode 100644 index 0000000000..ede2bce7ae --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InPredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * IN Predicate class. + */ +public class InPredicate extends FilterPredicate { + + public InPredicate(Path path, List values) { + super(path, Operator.IN, values); + } + + @SafeVarargs + public InPredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public InPredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public InPredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InfixInsensitivePredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InfixInsensitivePredicate.java new file mode 100644 index 0000000000..58690d5da1 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InfixInsensitivePredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * INFIX Insensitive Predicate class. + */ +public class InfixInsensitivePredicate extends FilterPredicate { + + public InfixInsensitivePredicate(Path path, List values) { + super(path, Operator.INFIX_CASE_INSENSITIVE, values); + } + + @SafeVarargs + public InfixInsensitivePredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public InfixInsensitivePredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public InfixInsensitivePredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InfixPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InfixPredicate.java new file mode 100644 index 0000000000..aa6c969b4b --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/InfixPredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * INFIX Predicate class. + */ +public class InfixPredicate extends FilterPredicate { + + public InfixPredicate(Path path, List values) { + super(path, Operator.INFIX, values); + } + + @SafeVarargs + public InfixPredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public InfixPredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public InfixPredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/IsEmptyPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/IsEmptyPredicate.java new file mode 100644 index 0000000000..3c3d66d421 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/IsEmptyPredicate.java @@ -0,0 +1,25 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * Is Empty Predicate Class. + */ +public class IsEmptyPredicate extends FilterPredicate { + + public IsEmptyPredicate(Path path) { + super(path, Operator.ISEMPTY, Collections.emptyList()); + } + + public IsEmptyPredicate(Path.PathElement pathElement) { + super(pathElement, Operator.ISEMPTY, Collections.emptyList()); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/IsNullPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/IsNullPredicate.java new file mode 100644 index 0000000000..8668913659 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/IsNullPredicate.java @@ -0,0 +1,26 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * ISNULL Predicate class. + */ +public class IsNullPredicate extends FilterPredicate { + + public IsNullPredicate(Path path) { + super(path, Operator.ISNULL, Collections.emptyList()); + } + + public IsNullPredicate(PathElement pathElement) { + this(new Path(Collections.singletonList(pathElement))); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/LEPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/LEPredicate.java new file mode 100644 index 0000000000..a7babd4f9f --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/LEPredicate.java @@ -0,0 +1,26 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * LE Predicate class. + */ +public class LEPredicate extends FilterPredicate { + + public LEPredicate(Path path, Object value) { + super(path, Operator.LE, Collections.singletonList(value)); + } + + public LEPredicate(PathElement pathElement, Object value) { + this(new Path(Collections.singletonList(pathElement)), value); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/LTPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/LTPredicate.java new file mode 100644 index 0000000000..fd9aa7bb4a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/LTPredicate.java @@ -0,0 +1,26 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * LT Predicate class. + */ +public class LTPredicate extends FilterPredicate { + + public LTPredicate(Path path, Object value) { + super(path, Operator.LT, Collections.singletonList(value)); + } + + public LTPredicate(PathElement pathElement, Object value) { + this(new Path(Collections.singletonList(pathElement)), value); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotEmptyPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotEmptyPredicate.java new file mode 100644 index 0000000000..f82a190cf6 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotEmptyPredicate.java @@ -0,0 +1,25 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * Not Empty Predicate Class. + */ +public class NotEmptyPredicate extends FilterPredicate { + + public NotEmptyPredicate(Path path) { + super(path, Operator.NOTEMPTY, Collections.emptyList()); + } + + public NotEmptyPredicate(Path.PathElement pathElement) { + super(pathElement, Operator.NOTEMPTY, Collections.emptyList()); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotInInsensitivePredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotInInsensitivePredicate.java new file mode 100644 index 0000000000..63e51a4d1e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotInInsensitivePredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * NOT Insensitive Predicate class. + */ +public class NotInInsensitivePredicate extends FilterPredicate { + + public NotInInsensitivePredicate(Path path, List values) { + super(path, Operator.NOT_INSENSITIVE, values); + } + + @SafeVarargs + public NotInInsensitivePredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public NotInInsensitivePredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public NotInInsensitivePredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotInPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotInPredicate.java new file mode 100644 index 0000000000..efc16979d8 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotInPredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Not In Predicate class. + */ +public class NotInPredicate extends FilterPredicate { + + public NotInPredicate(Path path, List values) { + super(path, Operator.NOT, values); + } + + @SafeVarargs + public NotInPredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public NotInPredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public NotInPredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotNullPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotNullPredicate.java new file mode 100644 index 0000000000..ac41c7cd78 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/NotNullPredicate.java @@ -0,0 +1,26 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * Not NUL Predicate class. + */ +public class NotNullPredicate extends FilterPredicate { + + public NotNullPredicate(Path path) { + super(path, Operator.NOTNULL, Collections.emptyList()); + } + + public NotNullPredicate(PathElement pathElement) { + this(new Path(Collections.singletonList(pathElement))); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PostfixInsensitivePredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PostfixInsensitivePredicate.java new file mode 100644 index 0000000000..1b74299f06 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PostfixInsensitivePredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * POSTFIX Predicate class. + */ +public class PostfixInsensitivePredicate extends FilterPredicate { + + public PostfixInsensitivePredicate(Path path, List values) { + super(path, Operator.POSTFIX_CASE_INSENSITIVE, values); + } + + @SafeVarargs + public PostfixInsensitivePredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public PostfixInsensitivePredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public PostfixInsensitivePredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PostfixPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PostfixPredicate.java new file mode 100644 index 0000000000..f4a17f8a10 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PostfixPredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * POSTFIX Predicate class. + */ +public class PostfixPredicate extends FilterPredicate { + + public PostfixPredicate(Path path, List values) { + super(path, Operator.POSTFIX, values); + } + + @SafeVarargs + public PostfixPredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public PostfixPredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public PostfixPredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PrefixInsensitivePredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PrefixInsensitivePredicate.java new file mode 100644 index 0000000000..f641f6a0d9 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PrefixInsensitivePredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * PREFIX Predicate class. + */ +public class PrefixInsensitivePredicate extends FilterPredicate { + + public PrefixInsensitivePredicate(Path path, List values) { + super(path, Operator.PREFIX_CASE_INSENSITIVE, values); + } + + @SafeVarargs + public PrefixInsensitivePredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public PrefixInsensitivePredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public PrefixInsensitivePredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PrefixPredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PrefixPredicate.java new file mode 100644 index 0000000000..41ccd2cce5 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/PrefixPredicate.java @@ -0,0 +1,38 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * PREFIX Predicate class. + */ +public class PrefixPredicate extends FilterPredicate { + + public PrefixPredicate(Path path, List values) { + super(path, Operator.PREFIX, values); + } + + @SafeVarargs + public PrefixPredicate(Path path, T... a) { + this(path, Arrays.asList(a)); + } + + public PrefixPredicate(PathElement pathElement, List values) { + this(new Path(Collections.singletonList(pathElement)), values); + } + + @SafeVarargs + public PrefixPredicate(PathElement pathElement, T... a) { + this(pathElement, Arrays.asList(a)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/TruePredicate.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/TruePredicate.java new file mode 100644 index 0000000000..2583676317 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/predicates/TruePredicate.java @@ -0,0 +1,26 @@ +/* + * 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.filter.predicates; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.filter.Operator; + +import java.util.Collections; + +/** + * TRUE Predicate class. + */ +public class TruePredicate extends FilterPredicate { + + public TruePredicate(Path path) { + super(path, Operator.TRUE, Collections.emptyList()); + } + + public TruePredicate(PathElement pathElement) { + this(new Path(Collections.singletonList(pathElement))); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/FilterExpressionCheckEvaluationVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionCheckEvaluationVisitor.java similarity index 78% rename from elide-core/src/main/java/com/yahoo/elide/parsers/expression/FilterExpressionCheckEvaluationVisitor.java rename to elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionCheckEvaluationVisitor.java index 767e2bbcd4..55d21b7a56 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/FilterExpressionCheckEvaluationVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionCheckEvaluationVisitor.java @@ -4,23 +4,22 @@ * See LICENSE file in project root for terms. */ -package com.yahoo.elide.parsers.expression; +package com.yahoo.elide.core.filter.visitors; - -import com.yahoo.elide.core.filter.Predicate; import com.yahoo.elide.core.filter.expression.AndFilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpression; +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.core.filter.expression.Visitor; -import com.yahoo.elide.security.FilterExpressionCheck; -import com.yahoo.elide.security.RequestScope; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.security.checks.FilterExpressionCheck; /** * FilterExpressionCheckEvaluationVisitor evaluate a check against fields of a returning object from datastore. */ -public class FilterExpressionCheckEvaluationVisitor implements Visitor { +public class FilterExpressionCheckEvaluationVisitor implements FilterExpressionVisitor { private final Object object; private final FilterExpressionCheck filterExpressionCheck; private final RequestScope requestScope; @@ -33,8 +32,8 @@ public FilterExpressionCheckEvaluationVisitor(Object object, } @Override - public Boolean visitPredicate(Predicate predicate) { - return filterExpressionCheck.applyPredicateToObject(object, predicate, requestScope); + public Boolean visitPredicate(FilterPredicate filterPredicate) { + return filterExpressionCheck.applyPredicateToObject(object, filterPredicate, requestScope); } @Override diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionNormalizationVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionNormalizationVisitor.java new file mode 100644 index 0000000000..57b7fda149 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionNormalizationVisitor.java @@ -0,0 +1,66 @@ +/* + * 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.filter.visitors; + +import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpression; +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.core.filter.predicates.FilterPredicate; + + +/** + * Expression Visitor. + */ +public class FilterExpressionNormalizationVisitor implements FilterExpressionVisitor { + + @Override + public FilterExpression visitPredicate(FilterPredicate filterPredicate) { + return filterPredicate; + } + + @Override + public FilterExpression visitAndExpression(AndFilterExpression expression) { + FilterExpression left = expression.getLeft(); + FilterExpression right = expression.getRight(); + return new AndFilterExpression(left.accept(this), right.accept(this)); + } + + @Override + public FilterExpression visitOrExpression(OrFilterExpression expression) { + FilterExpression left = expression.getLeft(); + FilterExpression right = expression.getRight(); + return new OrFilterExpression(left.accept(this), right.accept(this)); + } + + @Override + public FilterExpression visitNotExpression(NotFilterExpression fe) { + FilterExpression inner = fe.getNegated(); + if (inner instanceof AndFilterExpression) { + AndFilterExpression and = (AndFilterExpression) inner; + FilterExpression left = new NotFilterExpression(and.getLeft()).accept(this); + FilterExpression right = new NotFilterExpression(and.getRight()).accept(this); + return new OrFilterExpression(left, right); + } + if (inner instanceof OrFilterExpression) { + OrFilterExpression or = (OrFilterExpression) inner; + FilterExpression left = new NotFilterExpression(or.getLeft()).accept(this); + FilterExpression right = new NotFilterExpression(or.getRight()).accept(this); + return new AndFilterExpression(left, right); + } + if (inner instanceof NotFilterExpression) { + NotFilterExpression not = (NotFilterExpression) inner; + return (not.getNegated()).accept(this); + } + if (inner instanceof FilterPredicate) { + FilterPredicate filter = (FilterPredicate) inner; + return filter.negate(); + } + return inner; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/VerifyFieldAccessFilterExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/VerifyFieldAccessFilterExpressionVisitor.java new file mode 100644 index 0000000000..141084ed9e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/VerifyFieldAccessFilterExpressionVisitor.java @@ -0,0 +1,180 @@ +/* + * 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.filter.visitors; + +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.core.Path.PathElement; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.RelationshipType; +import com.yahoo.elide.core.exceptions.ForbiddenAccessException; +import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpression; +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.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.core.security.PermissionExecutor; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import io.reactivex.Observable; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Enforce read permission on filter join. + */ +public class VerifyFieldAccessFilterExpressionVisitor implements FilterExpressionVisitor { + private PersistentResource resource; + + public VerifyFieldAccessFilterExpressionVisitor(PersistentResource resource) { + this.resource = resource; + } + + /** + * Enforce ReadPermission on provided query filter. + * + * @return true if allowed, false if rejected + */ + @Override + public Boolean visitPredicate(FilterPredicate filterPredicate) { + RequestScope requestScope = resource.getRequestScope(); + Set val = Collections.singleton(resource); + + PermissionExecutor permissionExecutor = requestScope.getPermissionExecutor(); + + ExpressionResult result = permissionExecutor.evaluateFilterJoinUserChecks(resource, filterPredicate); + + if (result == ExpressionResult.UNEVALUATED) { + result = evaluateUserChecks(filterPredicate, permissionExecutor); + } + if (result == ExpressionResult.PASS) { + return true; + } + if (result == ExpressionResult.FAIL) { + return false; + } + + for (PathElement element : filterPredicate.getPath().getPathElements()) { + String fieldName = element.getFieldName(); + + if ("this".equals(fieldName)) { + continue; + } + + try { + val = val.stream() + .filter(Objects::nonNull) + .flatMap(x -> + getValueChecked(x, fieldName, requestScope) + .toList(LinkedHashSet::new) + .blockingGet() + .stream()) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } catch (ForbiddenAccessException e) { + result = permissionExecutor.handleFilterJoinReject(filterPredicate, element, e); + if (result == ExpressionResult.DEFERRED) { + continue; + } + // pass or fail + return result == ExpressionResult.PASS; + } + } + return true; + } + + private Observable getValueChecked(PersistentResource resource, String fieldName, + RequestScope requestScope) { + + EntityDictionary dictionary = resource.getDictionary(); + + // checkFieldAwareReadPermissions + requestScope.getPermissionExecutor().checkSpecificFieldPermissions(resource, null, ReadPermission.class, + fieldName); + Object entity = resource.getObject(); + if (entity == null || resource.getDictionary() + .getRelationshipType(resource.getResourceType(), fieldName) == RelationshipType.NONE) { + return Observable.empty(); + } + + Relationship relationship = Relationship.builder() + .name(fieldName) + .alias(fieldName) + .projection(EntityProjection.builder() + .type(dictionary.getParameterizedType(resource.getResourceType(), fieldName)) + .build()) + .build(); + // use no filter to allow the read directly from loaded resource + return resource.getRelationChecked(relationship); + } + + /** + * Scan the Path for user checks. + *
    + *
  1. If all are PASS, return PASS + *
  2. If any FAIL, return FAIL + *
  3. Otherwise return DEFERRED + *
+ * @param filterPredicate filterPredicate + * @param permissionExecutor permissionExecutor + * @return ExpressionResult + */ + private ExpressionResult evaluateUserChecks(FilterPredicate filterPredicate, + PermissionExecutor permissionExecutor) { + PermissionExecutor executor = resource.getRequestScope().getPermissionExecutor(); + + ExpressionResult ret = ExpressionResult.PASS; + for (PathElement element : filterPredicate.getPath().getPathElements()) { + ExpressionResult result; + try { + result = executor.checkUserPermissions( + element.getType(), + ReadPermission.class, + element.getFieldName()); + } catch (ForbiddenAccessException e) { + result = permissionExecutor.handleFilterJoinReject(filterPredicate, element, e); + } + + if (result == ExpressionResult.FAIL) { + return ExpressionResult.FAIL; + } + + if (result != ExpressionResult.PASS) { + ret = ExpressionResult.DEFERRED; + } + } + return ret; + } + + @Override + public Boolean visitAndExpression(AndFilterExpression expression) { + FilterExpression left = expression.getLeft(); + FilterExpression right = expression.getRight(); + // are both allowed + return left.accept(this) && right.accept(this); + } + + @Override + public Boolean visitOrExpression(OrFilterExpression expression) { + FilterExpression left = expression.getLeft(); + FilterExpression right = expression.getRight(); + // are both allowed + return left.accept(this) && right.accept(this); + } + + @Override + public Boolean visitNotExpression(NotFilterExpression expression) { + // is negated expression allowed + return expression.getNegated().accept(this); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/CRUDEvent.java b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/CRUDEvent.java new file mode 100644 index 0000000000..110f32312f --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/CRUDEvent.java @@ -0,0 +1,42 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.lifecycle; + +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.UPDATE; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.PersistentResource; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.Optional; + +/** + * Captures all the bits related to a CRUD operation on a model. + */ +@Data +@AllArgsConstructor +public class CRUDEvent { + private LifeCycleHookBinding.Operation eventType; + private PersistentResource resource; + private String fieldName; + private Optional changes; + + public boolean isCreateEvent() { + return eventType == CREATE; + } + + public boolean isUpdateEvent() { + return eventType == UPDATE; + } + + public boolean isDeleteEvent() { + return eventType == DELETE; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifeCycleHook.java b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifeCycleHook.java new file mode 100644 index 0000000000..286dc021a9 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifeCycleHook.java @@ -0,0 +1,52 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.lifecycle; + +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; + +import java.util.Optional; + +/** + * Function which will be invoked for Elide lifecycle triggers. + * @param The elide entity type associated with this callback. + */ +@FunctionalInterface +public interface LifeCycleHook { + /** + * Run for a lifecycle event. + * @param operation CREATE, READ, UPDATE, or DELETE + * @param phase PRESECURITY, PRECOMMIT or POSTCOMMIT + * @param elideEntity The entity that triggered the event + * @param requestScope The request scope + * @param changes Optionally, the changes that were made to the entity + */ + void execute(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + T elideEntity, + RequestScope requestScope, + Optional changes); + + /** + * Base method of life cycle hook invoked by Elide. Includes access to the underlying + * CRUDEvent and the PersistentResource. + * @param operation CREATE, READ, UPDATE, or DELETE + * @param phase PRESECURITY, PRECOMMIT or POSTCOMMIT + * @param event The CRUD Event that triggered this hook. + */ + default void execute( + LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + CRUDEvent event) { + this.execute( + operation, + phase, + (T) event.getResource().getObject(), + event.getResource().getRequestScope(), + event.getChanges()); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifecycleHookInvoker.java b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifecycleHookInvoker.java new file mode 100644 index 0000000000..8ffbc70b2f --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifecycleHookInvoker.java @@ -0,0 +1,46 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.lifecycle; + +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.dictionary.EntityDictionary; + +import java.util.ArrayList; + +/** + * RX Java Observer which invokes a lifecycle hook function. + */ +public class LifecycleHookInvoker { + + private EntityDictionary dictionary; + private LifeCycleHookBinding.Operation op; + private LifeCycleHookBinding.TransactionPhase phase; + + public LifecycleHookInvoker(EntityDictionary dictionary, + LifeCycleHookBinding.Operation op, + LifeCycleHookBinding.TransactionPhase phase) { + this.dictionary = dictionary; + this.op = op; + this.phase = phase; + } + + 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().getResourceType(), op, phase, event.getFieldName())); + + //Collect all the hooks that are keyed on any field. + if (!event.getFieldName().isEmpty()) { + hooks.addAll(dictionary.getTriggers(event.getResource().getResourceType(), op, phase)); + } + + //Invoke all the hooks + hooks.forEach(hook -> + hook.execute(this.op, this.phase, event) + ); + } +} 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 7388281ad0..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/pagination/Pagination.java +++ /dev/null @@ -1,212 +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.annotation.Paginate; -import com.yahoo.elide.core.exceptions.InvalidValueException; -import lombok.Getter; -import lombok.ToString; - -import javax.ws.rs.core.MultivaluedMap; -import java.util.HashMap; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * 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 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 Pagination(Map pageData) { - this.pageData = pageData; - } - - /** - * Given json-api paging params, generate page and pageSize values from query params. - * - * @param queryParams The page queryParams (ImmuatableMultiValueMap). - * @return The new Page object. - */ - public static Pagination parseQueryParams(final MultivaluedMap queryParams) - 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 new Pagination(pageData).evaluate(DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); - } - - /** - * Evaluates the pagination variables for default limits - * - * @param defaultLimit - * @param maxLimit - */ - public Pagination evaluate(int defaultLimit, int maxLimit) { - - if (pageData.containsKey(PaginationKey.size) || pageData.containsKey(PaginationKey.number)) { - // Page-based pagination strategy - - if (pageData.containsKey(PaginationKey.limit) || pageData.containsKey(PaginationKey.offset)) { - throw new InvalidValueException("Invalid usage of pagination parameters."); - } - - 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); - } else 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 > 0 ? (pageNumber - 1) * limit : 0; - } else if (pageData.containsKey(PaginationKey.limit) || pageData.containsKey(PaginationKey.offset)) { - - // Offset-based pagination strategy - 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); - } - - offset = pageData.containsKey(PaginationKey.offset) ? pageData.get(PaginationKey.offset) : 0; - - if (limit < 0 || offset < 0) { - throw new InvalidValueException("page[offset] and page[limit] must contain positive values."); - } - - } else { - limit = defaultLimit; - offset = 0; - } - - generateTotals = pageData.containsKey(PaginationKey.totals); - - return this; - } - - /** - * Evaluates the pagination variables. Uses the Paginate annotation if it has been set for the entity to be - * queried. - * - * @param entityClass - */ - public Pagination evaluate(final Class entityClass) { - Paginate paginate = - entityClass != null ? (Paginate) entityClass.getAnnotation(Paginate.class) : null; - - int defaultLimit = paginate != null ? paginate.defaultLimit() : DEFAULT_PAGE_LIMIT; - int maxLimit = paginate != null ? paginate.maxLimit() : MAX_PAGE_LIMIT; - - 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. - * @return The default instance. - */ - public static Pagination getDefaultPagination() { - Pagination defaultPagination = new Pagination(new HashMap<>()); - 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..6ebfd7d3c7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/pagination/PaginationImpl.java @@ -0,0 +1,280 @@ +/* + * 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.core.request.Pagination; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.google.common.collect.ImmutableMap; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.util.HashMap; +import java.util.Map; +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 = ImmutableMap.of( + PAGE_NUMBER_KEY, PaginationKey.number, + PAGE_SIZE_KEY, PaginationKey.size, + PAGE_OFFSET_KEY, PaginationKey.offset, + PAGE_LIMIT_KEY, PaginationKey.limit, + 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 Type 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(ClassType.of(entityClass), clientOffset, clientLimit, + systemDefaultLimit, systemMaxLimit, generateTotals, pageByPages); + } + + /** + * 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(Type 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(Type entityClass, + final MultivaluedMap queryParams, + ElideSettings elideSettings) + throws InvalidValueException { + + if (queryParams.isEmpty()) { + return getDefaultPagination(entityClass, elideSettings); + } + + 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(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(Type 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(Type 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(Type 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/request/Argument.java b/elide-core/src/main/java/com/yahoo/elide/core/request/Argument.java new file mode 100644 index 0000000000..472d323687 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/request/Argument.java @@ -0,0 +1,94 @@ +/* + * 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.request; + +import static org.apache.commons.lang3.StringUtils.isEmpty; +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Represents an argument passed to an attribute. + */ +@Value +@Builder +public class Argument { + + // square brackets having non-empty argument name and encoded agument value separated by ':' + // eg: [grain:month] , [foo:bar][blah:Encoded+Value] + public static final Pattern ARGUMENTS_PATTERN = Pattern.compile("\\[(\\w+):([^\\]]+)\\]"); + + @NonNull + String name; + + Object value; + + /** + * Returns the argument type. + * @return the argument type. + */ + public Class getType() { + return value.getClass(); + } + + /** + * Parses input string and returns a set of {@link Argument}. + * + * @param argsString String to parse for arguments. + * @return A Set of {@link Argument}. + * @throws UnsupportedEncodingException + */ + public static Set getArgumentsFromString(String argsString) throws UnsupportedEncodingException { + Set arguments = new HashSet<>(); + + if (!isEmpty(argsString)) { + + Matcher matcher = ARGUMENTS_PATTERN.matcher(argsString); + while (matcher.find()) { + arguments.add(Argument.builder() + .name(matcher.group(1)) + .value(URLDecoder.decode(matcher.group(2), StandardCharsets.UTF_8.name())) + .build()); + } + } + + return arguments; + } + + /** + * Converts Set of {@link Argument} into Map. + * @param arguments Set of {@link Argument}. + * @return a Map of {@link Argument}. + */ + public static Map getArgumentMapFromArgumentSet(Set arguments) { + return arguments.stream() + .collect(Collectors.toMap(Argument::getName, Function.identity())); + } + + /** + * Parses input string and returns a Map of {@link Argument}. + * + * @param argsString String to parse for arguments. + * @return a Map of {@link Argument}. + * @throws UnsupportedEncodingException + */ + public static Map getArgumentMapFromString(String argsString) + throws UnsupportedEncodingException { + return getArgumentMapFromArgumentSet(getArgumentsFromString(argsString)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/request/Attribute.java b/elide-core/src/main/java/com/yahoo/elide/core/request/Attribute.java new file mode 100644 index 0000000000..4e664e0591 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/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.core.request; + +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +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 Type type; + + @NonNull + private String name; + + @ToString.Exclude + private String alias; + + @Singular + @ToString.Exclude + private Set arguments; + + private Attribute(@NonNull Type type, @NonNull String name, String alias, Set arguments) { + this.type = type; + this.name = name; + this.alias = alias == null ? name : alias; + this.arguments = arguments; + } + + public static class AttributeBuilder { + + public Attribute.AttributeBuilder type(Type type) { + this.type = type; + return this; + } + + public Attribute.AttributeBuilder type(Class type) { + this.type = ClassType.of(type); + return this; + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/request/EntityProjection.java b/elide-core/src/main/java/com/yahoo/elide/core/request/EntityProjection.java new file mode 100644 index 0000000000..46f5d2eddf --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/request/EntityProjection.java @@ -0,0 +1,320 @@ +/* + * 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.request; + +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.google.common.collect.Sets; +import com.google.common.collect.Streams; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Represents a client data request against a subgraph of the entity relationship graph. + */ +@Data +@Builder +@AllArgsConstructor +public class EntityProjection { + @NonNull + private Type type; + + private Set attributes; + + private Set relationships; + + private FilterExpression filterExpression; + + private Sorting sorting; + + private Pagination pagination; + //TODO: Remove this exclude + @ToString.Exclude + private Set arguments; + + public Set getRequestedFields() { + return Streams.concat( + attributes.stream().map(Attribute::getName), + relationships.stream().map(Relationship::getName) + ).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** + * 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) + .arguments(new LinkedHashSet<>(this.arguments)); + } + + public Set getIncludedRelationsName() { + return getRelationships().stream() + .map(relationship -> relationship.getName()).collect(Collectors.toSet()); + } + + /** + * 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); + + merged.arguments.addAll(toMerge.arguments); + + return merged.build(); + } + + /** + * Customizes the lombok builder to our needs. + */ + public static class EntityProjectionBuilder { + @Getter + private Type type; + + private Set relationships = new LinkedHashSet<>(); + + @Getter + private Set attributes = new LinkedHashSet<>(); + + @Getter + private Set arguments = new LinkedHashSet<>(); + + @Getter + private FilterExpression filterExpression; + + @Getter + private Sorting sorting; + + @Getter + private Pagination pagination; + + public EntityProjectionBuilder type(Type type) { + this.type = type; + return this; + } + + public EntityProjectionBuilder type(Class cls) { + this.type = ClassType.of(cls); + return this; + } + + public EntityProjectionBuilder relationships(Set relationships) { + this.relationships = relationships; + return this; + } + + public EntityProjectionBuilder attributes(Set attributes) { + this.attributes = attributes; + return this; + } + + public EntityProjectionBuilder arguments(Set arguments) { + this.arguments = arguments; + return this; + } + + public EntityProjectionBuilder relationship(String name, EntityProjection projection) { + return relationship(Relationship.builder() + .alias(name) + .name(name) + .projection(projection) + .build()); + } + + /** + * Add a new argument into this project or merge an existing argument that has same name. + * + * argument argument new argument to add + * @return this builder after adding the argument + */ + public EntityProjectionBuilder argument(Argument argument) { + String argumentName = argument.getName(); + + Argument existing = arguments.stream() + .filter(a -> a.getName().equals(argumentName)) + .findFirst().orElse(null); + + if (existing != null) { + arguments.remove(existing); + } + arguments.add(argument); + + return this; + } + + /** + * 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() + .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()) + .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); + } + + /** + * 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/core/request/Pagination.java b/elide-core/src/main/java/com/yahoo/elide/core/request/Pagination.java new file mode 100644 index 0000000000..b62e9fe6a3 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/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.core.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/core/request/Relationship.java b/elide-core/src/main/java/com/yahoo/elide/core/request/Relationship.java new file mode 100644 index 0000000000..4cd05e7d69 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/request/Relationship.java @@ -0,0 +1,48 @@ +/* + * 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.request; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +/** + * Represents a relationship on an Elide entity. + */ +@Data +@Builder +public class Relationship { + + public RelationshipBuilder copyOf() { + return Relationship.builder() + .alias(alias) + .name(name) + .projection(projection); + } + + @NonNull + private String name; + + private String alias; + + @NonNull + private EntityProjection projection; + + private Relationship(@NonNull String name, String alias, @NonNull EntityProjection projection) { + this.name = name; + this.alias = alias == null ? name : alias; + this.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/core/request/Sorting.java b/elide-core/src/main/java/com/yahoo/elide/core/request/Sorting.java new file mode 100644 index 0000000000..d4fcb389a4 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/request/Sorting.java @@ -0,0 +1,42 @@ +/* + * 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.request; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.type.Type; + +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 Type 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/core/security/ChangeSpec.java b/elide-core/src/main/java/com/yahoo/elide/core/security/ChangeSpec.java new file mode 100644 index 0000000000..0fb2fcc863 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/ChangeSpec.java @@ -0,0 +1,37 @@ +/* + * 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.security; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * ChangeSpec for a particular field. + */ +@AllArgsConstructor +public class ChangeSpec { + @Getter private final PersistentResource resource; + @Getter private final String fieldName; + @Getter private final Object original; + @Getter private final Object modified; + + @Override + public String toString() { + return String.format("ChangeSpec { resource=%s, field=%s, original=%s, modified=%s}", + safe(resource), + fieldName, + safe(original), + safe(modified)); + } + + private String safe(Object object) { + try { + return String.valueOf(object); + } catch (Exception e) { + return e.toString(); + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/PermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/PermissionExecutor.java new file mode 100644 index 0000000000..ae5d0e4ff5 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/PermissionExecutor.java @@ -0,0 +1,185 @@ +/* + * 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.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.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.filter.visitors.VerifyFieldAccessFilterExpressionVisitor; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.type.Type; + +import java.lang.annotation.Annotation; +import java.util.Optional; +import java.util.Set; + +/** + * Interface describing classes responsible for managing the life-cycle and execution of checks. + * + * Checks are expected to throw exceptions upon failures. + */ +public interface PermissionExecutor { + /** + * Check permission on class. + * + * @param type parameter + * @param annotationClass annotation class + * @param resource resource + * @see com.yahoo.elide.annotation.CreatePermission + * @see com.yahoo.elide.annotation.ReadPermission + * @see com.yahoo.elide.annotation.UpdatePermission + * @see com.yahoo.elide.annotation.DeletePermission + * @return the results of evaluating the permission + */ + default ExpressionResult checkPermission( + Class annotationClass, + PersistentResource resource + ) { + return checkPermission(annotationClass, resource, null); + } + + /** + * Check permission on class. + * + * @param type parameter + * @param annotationClass annotation class + * @param resource resource + * @param requestedFields the list of requested fields + * @see com.yahoo.elide.annotation.CreatePermission + * @see com.yahoo.elide.annotation.ReadPermission + * @see com.yahoo.elide.annotation.UpdatePermission + * @see com.yahoo.elide.annotation.DeletePermission + * @return the results of evaluating the permission + */ + ExpressionResult checkPermission( + Class annotationClass, + PersistentResource resource, + Set requestedFields + ); + + /** + * Check for permissions on a specific field. + * + * @param type parameter + * @param resource resource + * @param changeSpec changepsec + * @param annotationClass annotation class + * @param field field to check + * @return the results of evaluating the permission + */ + ExpressionResult checkSpecificFieldPermissions(PersistentResource resource, + ChangeSpec changeSpec, + Class annotationClass, + String field); + + /** + * Check for permissions on a specific field deferring all checks. + * + * @param type parameter + * @param resource resource + * @param changeSpec changepsec + * @param annotationClass annotation class + * @param field field to check + * @return the results of evaluating the permission + */ + ExpressionResult checkSpecificFieldPermissionsDeferred(PersistentResource resource, + ChangeSpec changeSpec, + Class annotationClass, + String field); + + /** + * Check strictly user permissions on an entity. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param requestedFields The list of client requested fields + * @return the results of evaluating the permission + */ + ExpressionResult checkUserPermissions( + Type resourceClass, + Class annotationClass, + Set requestedFields + ); + + /** + * Check strictly user permissions on an entity field. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param field The entity field + */ + public ExpressionResult checkUserPermissions(Type resourceClass, + Class annotationClass, + String field); + + /** + * Get the read filter, if defined. + * + * @param resourceClass the class to check for a filter + * @param requestedFields the set of requested fields + * @return the an optional containg the filter + */ + Optional getReadPermissionFilter(Type resourceClass, Set requestedFields); + + /** + * Execute commit checks. + */ + void executeCommitChecks(); + + /** + * Logs useful information about the check evaluation. + * + */ + default void logCheckStats() { + } + + /** + * Whether or not the permission executor will return verbose logging to the requesting user in the response. + * + * @return True if verbose, false otherwise. + */ + default boolean isVerbose() { + return false; + } + + /** + * Evaluate filterPredicate for a provided resource, or return PASS or FAIL. + * Return UNEVALUATED for default handling. + * Return DEFERRED to skip default user check handling. + * @see VerifyFieldAccessFilterExpressionVisitor#visitPredicate + * + * @param resource resource + * @param filterPredicate filterPredicate + * @return PASS, FAIL or UNEVALUATED + */ + default ExpressionResult evaluateFilterJoinUserChecks(PersistentResource resource, + FilterPredicate filterPredicate) { + return ExpressionResult.UNEVALUATED; + } + + /** + * Allow customized enforcement of ReadPermission for filter joins in VerifyFieldAccessFilterExpressionVisitor + * Return PASS to allow filtering on the unreadable element and stop evaluating the path. + * Return DEFERRED to allow filtering on the the unreadable element and continue checking the path. + * Return FAILED to reject filtering on the unreadable field. This is the default. + * @see VerifyFieldAccessFilterExpressionVisitor#visitPredicate + * + * @param filterPredicate filterPredicate + * @param pathElement pathElement + * @param reason ForbiddenAccessException + * @return PASS, FAIL or DEFERRED + */ + default ExpressionResult handleFilterJoinReject( + FilterPredicate filterPredicate, + PathElement pathElement, + ForbiddenAccessException reason) { + return ExpressionResult.FAIL; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/PersistentResource.java b/elide-core/src/main/java/com/yahoo/elide/core/security/PersistentResource.java new file mode 100644 index 0000000000..46bddfb2ec --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/PersistentResource.java @@ -0,0 +1,27 @@ +/* + * 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.security; + +import com.yahoo.elide.core.type.Type; + +import java.util.Optional; + +/** + * The persistent resource interface passed to change specs. + * @param resource type + */ +public interface PersistentResource { + + boolean matchesId(String id); + + Optional getUUID(); + String getId(); + String getTypeName(); + + T getObject(); + Type getResourceType(); + RequestScope getRequestScope(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/RequestScope.java b/elide-core/src/main/java/com/yahoo/elide/core/security/RequestScope.java new file mode 100644 index 0000000000..fb57d3588a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/RequestScope.java @@ -0,0 +1,23 @@ +/* + * 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.security; + +import com.yahoo.elide.core.datastore.DataStoreTransaction; + +import java.util.List; +import java.util.Map; + +/** + * The request scope interface passed to checks. + */ +public interface RequestScope { + User getUser(); + String getApiVersion(); + String getRequestHeaderByName(String headerName); + String getBaseUrlEndPoint(); + Map> getQueryParams(); + DataStoreTransaction getTransaction(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/User.java b/elide-core/src/main/java/com/yahoo/elide/core/security/User.java new file mode 100644 index 0000000000..a5496a8e44 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/User.java @@ -0,0 +1,29 @@ +/* + * 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.security; + +import lombok.Getter; + +import java.security.Principal; + +/** + * Wrapper for opaque user passed in every request. + */ +public class User { + @Getter private final Principal principal; + + public User(Principal principal) { + this.principal = principal; + } + + public String getName() { + return principal != null ? principal.getName() : null; + } + + public boolean isInRole(String role) { + return false; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/checks/Check.java b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/Check.java new file mode 100644 index 0000000000..96d6a548d7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/Check.java @@ -0,0 +1,21 @@ +/* + * 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.security.checks; +/** + * 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 interface Check { + + /** + * Should the check forced to be run at transaction commit or not. + * + * @return true to run at transaction commit + */ + default boolean runAtCommit() { + return false; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/checks/FilterExpressionCheck.java b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/FilterExpressionCheck.java new file mode 100644 index 0000000000..6f5eec2908 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/FilterExpressionCheck.java @@ -0,0 +1,114 @@ +/* + * 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.security.checks; + +import com.yahoo.elide.annotation.FilterExpressionPath; +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.filter.visitors.FilterExpressionCheckEvaluationVisitor; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.type.Type; +import lombok.extern.slf4j.Slf4j; + +import java.util.Optional; +import java.util.function.Predicate; + +/** + * Check for FilterExpression. This is a super class for user defined FilterExpression check. The subclass should + * override getFilterExpression function and return a FilterExpression which will be passed down to datastore. + * + * @param Type of class + */ +@Slf4j +public abstract class FilterExpressionCheck extends OperationCheck { + + /** + * Returns a FilterExpression from FilterExpressionCheck. + * + * @param entityClass entity type + * @param requestScope Request scope object + * @return FilterExpression for FilterExpressionCheck. + */ + public abstract FilterExpression getFilterExpression(Type entityClass, RequestScope requestScope); + + /** + * The filter expression is evaluated in memory if it cannot be pushed to the data store by elide for any reason. + * + * @param object object returned from datastore + * @param requestScope Request scope object + * @param changeSpec Summary of modifications + * @return true if the object pass evaluation against FilterExpression. + */ + @Override + public final boolean ok(T object, RequestScope requestScope, Optional changeSpec) { + EntityDictionary dictionary = coreScope(requestScope).getDictionary(); + Type entityClass = dictionary.lookupBoundClass(EntityDictionary.getType(object)); + FilterExpression filterExpression = getFilterExpression(entityClass, requestScope); + return filterExpression.accept(new FilterExpressionCheckEvaluationVisitor(object, this, requestScope)); + } + + /** + * Applies a filter predicate to the object in question. + * + * @param object object returned from datastore + * @param filterPredicate A predicate from filterExpressionCheck + * @param requestScope Request scope object + * @return true if the object pass evaluation against Predicate. + */ + public boolean applyPredicateToObject(T object, FilterPredicate filterPredicate, RequestScope requestScope) { + try { + com.yahoo.elide.core.RequestScope scope = coreScope(requestScope); + Predicate fn = filterPredicate.getOperator() + .contextualize(filterPredicate.getPath(), filterPredicate.getValues(), scope); + return fn.test(object); + } catch (Exception e) { + log.error("Failed to apply predicate {}", filterPredicate, e); + return false; + } + } + + @Override + public final boolean runAtCommit() { + return false; + } + + /** + * Converts FieldExpressionPath value to corresponding list of Predicates. + * + * @param type entity + * @param requestScope request scope + * @param method associated check method name containing FieldExpressionPath + * @param defaultPath path to use if no FieldExpressionPath defined + * @return Predicates + */ + protected Path getFieldPath(Type type, RequestScope requestScope, String method, String defaultPath) { + EntityDictionary dictionary = coreScope(requestScope).getDictionary(); + 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( + Type 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) { + return (com.yahoo.elide.core.RequestScope) requestScope; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/checks/OperationCheck.java b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/OperationCheck.java new file mode 100644 index 0000000000..d4d6068e08 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/OperationCheck.java @@ -0,0 +1,34 @@ +/* + * 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.security.checks; + +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; + +import java.util.Optional; + +/** + * Operation check interface. + * @see Check + * + * Operation checks are run in-line (i.e. as soon as objects are first encountered). + * + * NOTE: For non-Read operations, the object passed to this interface is not guaranteed to be complete + * as it will run _BEFORE_ changes are made to the object. + * + * @param Type parameter + */ +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-core/src/main/java/com/yahoo/elide/core/security/checks/UserCheck.java b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/UserCheck.java new file mode 100644 index 0000000000..58b5b1f1c8 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/UserCheck.java @@ -0,0 +1,27 @@ +/* + * 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.security.checks; + +import com.yahoo.elide.core.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 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); + + @Override + public final boolean runAtCommit() { + return false; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Collections.java b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Collections.java new file mode 100644 index 0000000000..a9cda4c94e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Collections.java @@ -0,0 +1,69 @@ +/* + * 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.security.checks.prefab; + +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.security.checks.OperationCheck; + +import java.util.Collection; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * Checks to ensure that collections are only modified in a prescribed manner. + */ +public class Collections { + + private static Predicate changeSpecIsCollection = c -> c.getModified() instanceof Collection; + + // Suppresses default constructor, ensuring non-instantiability. + private Collections() { + throw new UnsupportedOperationException(); + } + + /** + * Use changeSpec to enforce that values were exclusively added to the collection. + * + * @param type collection to be validated + */ + public static class AppendOnly extends OperationCheck { + + @Override + public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { + return changeSpec + .filter(changeSpecIsCollection) + .filter(c -> collectionIsSuperset(c::getOriginal, c::getModified)) + .isPresent(); + } + } + + /** + * Use changeSpec to enforce that values were exclusively removed from the collection. + * + * @param type parameter + */ + public static class RemoveOnly extends OperationCheck { + + @Override + public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { + return changeSpec + .filter(changeSpecIsCollection) + .filter(c -> collectionIsSuperset(c::getModified, c::getOriginal)) + .isPresent(); + } + + } + + private static boolean collectionIsSuperset(Supplier base, Supplier potential) { + Collection baseCollection = (Collection) base.get(); + Collection potentialSuperset = (Collection) potential.get(); + return potentialSuperset.size() >= baseCollection.size() + && potentialSuperset.containsAll(baseCollection); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Common.java b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Common.java new file mode 100644 index 0000000000..55c5643373 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Common.java @@ -0,0 +1,31 @@ +/* + * 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.security.checks.prefab; + +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.security.checks.OperationCheck; + +import java.util.Optional; + +/** + * Checks that are generally applicable. + */ +public class Common { + /** + * 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 OperationCheck { + @Override + public boolean ok(T record, RequestScope requestScope, Optional changeSpec) { + return changeSpec.filter(c -> c.getModified() == null).isPresent(); + } + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Role.java b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Role.java new file mode 100644 index 0000000000..28308bc551 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Role.java @@ -0,0 +1,51 @@ +/* + * 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.security.checks.prefab; + +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.security.checks.UserCheck; + +/** + * Simple checks to always grant or deny. + */ +public class Role { + public static final String NONE_ROLE = "NONE"; + public static final String ALL_ROLE = "ALL"; + /** + * Check which always grants. + */ + public static class ALL extends UserCheck { + @Override + public boolean ok(User user) { + return true; + } + } + + /** + * Check which always denies. + */ + public static class NONE extends UserCheck { + @Override + 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-core/src/main/java/com/yahoo/elide/core/security/executors/AbstractPermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/AbstractPermissionExecutor.java new file mode 100644 index 0000000000..91ed24bf1c --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/AbstractPermissionExecutor.java @@ -0,0 +1,260 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.security.executors; + +import static com.yahoo.elide.core.security.permissions.ExpressionResult.DEFERRED; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.FAIL; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.PASS; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.exceptions.ForbiddenAccessException; +import com.yahoo.elide.core.security.PermissionExecutor; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.security.permissions.ExpressionResultCache; +import com.yahoo.elide.core.security.permissions.PermissionExpressionBuilder; +import com.yahoo.elide.core.security.permissions.expressions.Expression; +import com.yahoo.elide.core.type.Type; +import com.google.common.collect.ImmutableSet; +import org.apache.commons.lang3.tuple.Triple; +import org.slf4j.Logger; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.lang.annotation.Annotation; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Abstract Permission Executor with common permission executor functionalities. + */ +public abstract class AbstractPermissionExecutor implements PermissionExecutor { + private final Logger log; + protected final Queue commitCheckQueue = new LinkedBlockingQueue<>(); + + protected final RequestScope requestScope; + protected final PermissionExpressionBuilder expressionBuilder; + protected final Map, Type, ImmutableSet>, ExpressionResult> + userPermissionCheckCache; + protected final Map checkStats; + + public AbstractPermissionExecutor(Logger log, RequestScope requestScope) { + ExpressionResultCache cache = new ExpressionResultCache(); + this.log = log; + this.requestScope = requestScope; + this.expressionBuilder = new PermissionExpressionBuilder(cache, requestScope.getDictionary()); + userPermissionCheckCache = new HashMap<>(); + checkStats = new HashMap<>(); + } + + /** + * Execute commmit checks. + */ + @Override + public void executeCommitChecks() { + commitCheckQueue.forEach((expr) -> { + Expression expression = expr.getExpression(); + ExpressionResult result = expression.evaluate(Expression.EvaluationMode.ALL_CHECKS); + if (result == FAIL) { + ForbiddenAccessException e = new ForbiddenAccessException( + expr.getAnnotationClass(), expression, Expression.EvaluationMode.ALL_CHECKS); + if (log.isTraceEnabled()) { + log.trace("{}", e.getLoggedMessage()); + } + throw e; + } + }); + commitCheckQueue.clear(); + } + + /** + * First attempts to check user permissions (by looking in the cache and if not present by executing user + * permissions). If user permissions don't short circuit the check, run the provided expression executor. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param fields Set of all field names that is being accessed + * @param expressionSupplier Builds a permission expression. + * @param expressionExecutor Evaluates the expression (post user check evaluation) + */ + protected ExpressionResult checkPermissions( + Type resourceClass, + Class annotationClass, + Set fields, + Supplier expressionSupplier, + Optional> expressionExecutor) { + + // If the user check has already been evaluated before, return the result directly and save the building cost + ImmutableSet immutableFields = fields == null ? null : ImmutableSet.copyOf(fields); + ExpressionResult expressionResult + = userPermissionCheckCache.get(Triple.of(annotationClass, resourceClass, immutableFields)); + + if (expressionResult == PASS) { + return expressionResult; + } + + Expression expression = expressionSupplier.get(); + + if (expressionResult == null) { + expressionResult = executeExpressions( + expression, + annotationClass, + Expression.EvaluationMode.USER_CHECKS_ONLY); + + userPermissionCheckCache.put( + Triple.of(annotationClass, resourceClass, immutableFields), expressionResult); + + if (expressionResult == PASS) { + return expressionResult; + } + } + + return expressionExecutor + .map(executor -> executor.apply(expression)) + .orElse(expressionResult); + } + + /** + * Only executes user permissions. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param fields Set of all field names that is being accessed + * @param expressionSupplier Builds a permission expression. + */ + protected ExpressionResult checkOnlyUserPermissions( + Type resourceClass, + Class annotationClass, + Set fields, + Supplier expressionSupplier) { + + return checkPermissions( + resourceClass, + annotationClass, + fields, + expressionSupplier, + Optional.empty() + ); + } + + /** + * First attempts to check user permissions (by looking in the cache and if not present by executing user + * permissions). If user permissions don't short circuit the check, run the provided expression executor. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param fields Set of all field names that is being accessed + * @param expressionSupplier Builds a permission expression. + * @param expressionExecutor Evaluates the expression (post user check evaluation) + */ + protected ExpressionResult checkPermissions( + Type resourceClass, + Class annotationClass, + Set fields, + Supplier expressionSupplier, + Function expressionExecutor) { + + return checkPermissions( + resourceClass, + annotationClass, + fields, + expressionSupplier, + Optional.of(expressionExecutor) + ); + } + + /** + * Execute expressions. + * + * @param expression The expression to evaluate. + * @param annotationClass The permission associated with the expression. + * @param mode The evaluation mode of the expression. + */ + protected ExpressionResult executeExpressions(final Expression expression, + final Class annotationClass, + Expression.EvaluationMode mode) { + + ExpressionResult result = expression.evaluate(mode); + + // Record the check + if (log.isTraceEnabled()) { + String checkKey = expression.toString(); + Long checkOccurrences = checkStats.getOrDefault(checkKey, 0L) + 1; + checkStats.put(checkKey, checkOccurrences); + } + + if (result == DEFERRED) { + + /* + * Checking user checks only are an optimization step. We don't need to defer these checks because + * INLINE_ONLY checks will be evaluated later. Also, the user checks don't have + * the correct context to evaluate as COMMIT checks later. + */ + if (mode == Expression.EvaluationMode.USER_CHECKS_ONLY) { + return DEFERRED; + } + + + if (isInlineOnlyCheck(annotationClass)) { + // Force evaluation of checks that can only be executed inline. + result = expression.evaluate(Expression.EvaluationMode.ALL_CHECKS); + if (result == FAIL) { + ForbiddenAccessException e = new ForbiddenAccessException( + annotationClass, + expression, + Expression.EvaluationMode.ALL_CHECKS); + if (log.isTraceEnabled()) { + log.trace("{}", e.getLoggedMessage()); + } + throw e; + } + return result; + } + commitCheckQueue.add(new AbstractPermissionExecutor.QueuedCheck(expression, annotationClass)); + return DEFERRED; + } + if (result == FAIL) { + ForbiddenAccessException e = new ForbiddenAccessException(annotationClass, expression, mode); + if (log.isTraceEnabled()) { + log.trace("{}", e.getLoggedMessage()); + } + throw e; + } + + return result; + } + + /** + * Check whether or not this check can only be run inline or not. + * + * @param annotationClass annotation class + * @return True if check can only be run inline, false otherwise. + */ + private boolean isInlineOnlyCheck(final Class annotationClass) { + return ReadPermission.class.isAssignableFrom(annotationClass) + || DeletePermission.class.isAssignableFrom(annotationClass); + } + + /** + * Information container about queued checks. + */ + @AllArgsConstructor + private static class QueuedCheck { + @Getter + private final Expression expression; + @Getter private final Class annotationClass; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/executors/ActivePermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/ActivePermissionExecutor.java new file mode 100644 index 0000000000..0ee5efdc56 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/ActivePermissionExecutor.java @@ -0,0 +1,491 @@ +/* + * 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.security.executors; + +import static com.yahoo.elide.core.security.permissions.ExpressionResult.DEFERRED; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.FAIL; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.PASS; +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.UpdatePermission; +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.core.security.ChangeSpec; +import com.yahoo.elide.core.security.PermissionExecutor; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.security.permissions.ExpressionResultCache; +import com.yahoo.elide.core.security.permissions.PermissionExpressionBuilder; +import com.yahoo.elide.core.security.permissions.expressions.Expression; +import com.yahoo.elide.core.type.Type; +import com.google.common.collect.ImmutableSet; +import org.apache.commons.lang3.tuple.Triple; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.lang.annotation.Annotation; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Default permission executor. + * This executor executes all security checks as outlined in the documentation. + */ +@Slf4j +public class ActivePermissionExecutor implements PermissionExecutor { + private final Queue commitCheckQueue = new LinkedBlockingQueue<>(); + + private final RequestScope requestScope; + private final PermissionExpressionBuilder expressionBuilder; + private final Map, Type, ImmutableSet>, ExpressionResult> + userPermissionCheckCache; + private final Map checkStats; + private final boolean verbose; + + /** + * Constructor. + * + * @param requestScope Request scope + */ + public ActivePermissionExecutor(final RequestScope requestScope) { + this(false, requestScope); + } + + /** + * Constructor. + * + * @param verbose True if executor should produce verbose output to caller + * @param requestScope Request scope + */ + public ActivePermissionExecutor(boolean verbose, final RequestScope requestScope) { + ExpressionResultCache cache = new ExpressionResultCache(); + + this.requestScope = requestScope; + this.expressionBuilder = new PermissionExpressionBuilder(cache, requestScope.getDictionary()); + userPermissionCheckCache = new HashMap<>(); + checkStats = new HashMap<>(); + this.verbose = verbose; + } + + @Override + public ExpressionResult checkPermission( + Class annotationClass, + PersistentResource resource, + Set requestedFields + + ) { + Supplier expressionSupplier = () -> { + if (NonTransferable.class == annotationClass) { + if (requestScope.getDictionary().isTransferable(resource.getResourceType())) { + return expressionBuilder.buildAnyFieldExpressions(resource, ReadPermission.class, + requestedFields, null); + } + return PermissionExpressionBuilder.FAIL_EXPRESSION; + } + return expressionBuilder.buildAnyFieldExpressions(resource, annotationClass, requestedFields, null); + }; + + Function expressionExecutor = (expression) -> { + // for newly created object in PatchRequest limit to User checks + if (resource.isNewlyCreated()) { + return executeUserChecksDeferInline(annotationClass, expression); + } + return executeExpressions(expression, annotationClass, Expression.EvaluationMode.INLINE_CHECKS_ONLY); + }; + + return checkPermissions( + resource.getResourceType(), + annotationClass, + requestedFields, + expressionSupplier, + expressionExecutor); + } + + /** + * We will only run User checks during patch extension for newly created objects because relationships are not yet + * complete. The inline checks may fail (relationships are not yet fixed up). If the user checks return deferred, we + * will defer all of the inline checks to commit phase. + */ + private ExpressionResult executeUserChecksDeferInline(Class annotationClass, + Expression expression) { + ExpressionResult result = + executeExpressions(expression, annotationClass, Expression.EvaluationMode.USER_CHECKS_ONLY); + if (result == DEFERRED) { + commitCheckQueue.add(new QueuedCheck(expression, annotationClass)); + } + return result; + } + + /** + * Check for permissions on a specific field. + * + * @param type parameter + * @param resource resource + * @param changeSpec changepsec + * @param annotationClass annotation class + * @param field field to check + */ + @Override + public ExpressionResult checkSpecificFieldPermissions(PersistentResource resource, + ChangeSpec changeSpec, + Class annotationClass, + String field) { + Supplier expressionSupplier = () -> + expressionBuilder.buildSpecificFieldExpressions(resource, annotationClass, field, changeSpec); + + Function expressionExecutor = expression -> + executeExpressions(expression, annotationClass, Expression.EvaluationMode.INLINE_CHECKS_ONLY); + + return checkPermissions( + resource.getResourceType(), + annotationClass, + Collections.singleton(field), + expressionSupplier, + expressionExecutor); + } + + /** + * Check for permissions on a specific field deferring all checks. + * + * @param type parameter + * @param resource resource + * @param changeSpec changepsec + * @param annotationClass annotation class + * @param field field to check + */ + @Override + public ExpressionResult checkSpecificFieldPermissionsDeferred(PersistentResource resource, + ChangeSpec changeSpec, + Class annotationClass, + String field) { + //We would want to evaluate the expression in the CreatePermission in case of + // update checks on newly created entities + Class expressionAnnotation = annotationClass.isAssignableFrom(UpdatePermission.class) + && requestScope.getNewResources().contains(resource) + ? CreatePermission.class + : annotationClass; + + Supplier expressionSupplier = () -> + expressionBuilder.buildSpecificFieldExpressions( + resource, + expressionAnnotation, + field, + changeSpec); + + Function expressionExecutor = (expression) -> { + if (requestScope.getNewPersistentResources().contains(resource)) { + return executeUserChecksDeferInline(expressionAnnotation, expression); + } + return executeExpressions(expression, expressionAnnotation, Expression.EvaluationMode.INLINE_CHECKS_ONLY); + }; + + return checkPermissions( + resource.getResourceType(), + expressionAnnotation, + Collections.singleton(field), + expressionSupplier, + expressionExecutor); + } + + /** + * Check strictly user permissions on an entity. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + */ + @Override + public ExpressionResult checkUserPermissions(Type resourceClass, + Class annotationClass, + Set requestedFields) { + Supplier expressionSupplier = () -> + expressionBuilder.buildUserCheckAnyExpression( + resourceClass, + annotationClass, + requestedFields, + requestScope); + + return checkOnlyUserPermissions( + resourceClass, + annotationClass, + requestedFields, + expressionSupplier); + } + + /** + * First attempts to check user permissions (by looking in the cache and if not present by executing user + * permissions). If user permissions don't short circuit the check, run the provided expression executor. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param fields Set of all field names that is being accessed + * @param expressionSupplier Builds a permission expression. + * @param expressionExecutor Evaluates the expression (post user check evaluation) + */ + protected ExpressionResult checkPermissions( + Type resourceClass, + Class annotationClass, + Set fields, + Supplier expressionSupplier, + Optional> expressionExecutor) { + + // If the user check has already been evaluated before, return the result directly and save the building cost + ImmutableSet immutableFields = fields == null ? null : ImmutableSet.copyOf(fields); + ExpressionResult expressionResult + = userPermissionCheckCache.get(Triple.of(annotationClass, resourceClass, immutableFields)); + + if (expressionResult == PASS) { + return expressionResult; + } + + Expression expression = expressionSupplier.get(); + + if (expressionResult == null) { + expressionResult = executeExpressions( + expression, + annotationClass, + Expression.EvaluationMode.USER_CHECKS_ONLY); + + userPermissionCheckCache.put( + Triple.of(annotationClass, resourceClass, immutableFields), expressionResult); + + if (expressionResult == PASS) { + return expressionResult; + } + } + + return expressionExecutor + .map(executor -> executor.apply(expression)) + .orElse(expressionResult); + } + + /** + * Only executes user permissions. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param fields Set of all field names that is being accessed + * @param expressionSupplier Builds a permission expression. + */ + protected ExpressionResult checkOnlyUserPermissions( + Type resourceClass, + Class annotationClass, + Set fields, + Supplier expressionSupplier) { + + return checkPermissions( + resourceClass, + annotationClass, + fields, + expressionSupplier, + Optional.empty() + ); + } + + /** + * First attempts to check user permissions (by looking in the cache and if not present by executing user + * permissions). If user permissions don't short circuit the check, run the provided expression executor. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param fields Set of all field names that is being accessed + * @param expressionSupplier Builds a permission expression. + * @param expressionExecutor Evaluates the expression (post user check evaluation) + */ + protected ExpressionResult checkPermissions( + Type resourceClass, + Class annotationClass, + Set fields, + Supplier expressionSupplier, + Function expressionExecutor) { + + return checkPermissions( + resourceClass, + annotationClass, + fields, + expressionSupplier, + Optional.of(expressionExecutor) + ); + } + + + /** + * Get permission filter on an entity. + * + * @param resourceClass Resource class + * @param requestedFields The set of requested fields + * @return the filter expression for the class, if any + */ + @Override + public Optional getReadPermissionFilter(Type resourceClass, Set requestedFields) { + FilterExpression filterExpression = + expressionBuilder.buildAnyFieldFilterExpression(resourceClass, requestScope, requestedFields); + + return Optional.ofNullable(filterExpression); + } + + /** + * Execute commmit checks. + */ + @Override + public void executeCommitChecks() { + commitCheckQueue.forEach((expr) -> { + Expression expression = expr.getExpression(); + ExpressionResult result = expression.evaluate(Expression.EvaluationMode.ALL_CHECKS); + if (result == FAIL) { + ForbiddenAccessException e = new ForbiddenAccessException( + expr.getAnnotationClass(), expression, Expression.EvaluationMode.ALL_CHECKS); + if (log.isTraceEnabled()) { + log.trace("{}", e.getLoggedMessage()); + } + throw e; + } + }); + commitCheckQueue.clear(); + } + + /** + * Execute expressions. + * + * @param expression The expression to evaluate. + * @param annotationClass The permission associated with the expression. + * @param mode The evaluation mode of the expression. + */ + private ExpressionResult executeExpressions(final Expression expression, + final Class annotationClass, + Expression.EvaluationMode mode) { + + ExpressionResult result = expression.evaluate(mode); + + // Record the check + if (log.isTraceEnabled()) { + String checkKey = expression.toString(); + Long checkOccurrences = checkStats.getOrDefault(checkKey, 0L) + 1; + checkStats.put(checkKey, checkOccurrences); + } + + if (result == DEFERRED) { + + /* + * Checking user checks only are an optimization step. We don't need to defer these checks because + * INLINE_ONLY checks will be evaluated later. Also, the user checks don't have + * the correct context to evaluate as COMMIT checks later. + */ + if (mode == Expression.EvaluationMode.USER_CHECKS_ONLY) { + return DEFERRED; + } + + + if (isInlineOnlyCheck(annotationClass)) { + // Force evaluation of checks that can only be executed inline. + result = expression.evaluate(Expression.EvaluationMode.ALL_CHECKS); + if (result == FAIL) { + ForbiddenAccessException e = new ForbiddenAccessException( + annotationClass, + expression, + Expression.EvaluationMode.ALL_CHECKS); + if (log.isTraceEnabled()) { + log.trace("{}", e.getLoggedMessage()); + } + throw e; + } + return result; + } + commitCheckQueue.add(new QueuedCheck(expression, annotationClass)); + return DEFERRED; + } + if (result == FAIL) { + ForbiddenAccessException e = new ForbiddenAccessException(annotationClass, expression, mode); + if (log.isTraceEnabled()) { + log.trace("{}", e.getLoggedMessage()); + } + throw e; + } + + return result; + } + + /** + * Check whether or not this check can only be run inline or not. + * + * @param annotationClass annotation class + * @return True if check can only be run inline, false otherwise. + */ + private boolean isInlineOnlyCheck(final Class annotationClass) { + return ReadPermission.class.isAssignableFrom(annotationClass) + || DeletePermission.class.isAssignableFrom(annotationClass); + } + + /** + * Information container about queued checks. + */ + @AllArgsConstructor + private static class QueuedCheck { + @Getter private final Expression expression; + @Getter private final Class annotationClass; + } + + /** + * Logs the permission check statistics. + * + */ + @Override + public void logCheckStats() { + if (log.isTraceEnabled()) { + StringBuilder sb = new StringBuilder("Permission Check Statistics:\n"); + checkStats.entrySet().stream() + .sorted(Map.Entry.comparingByValue()) + .forEachOrdered(e -> sb.append(e.getKey() + ": " + e.getValue() + "\n")); + String stats = sb.toString(); + log.trace(stats); + } + } + + @Override + public boolean isVerbose() { + return verbose; + } + + /** + * Check strictly user permissions on an entity field. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param field The entity field + */ + @Override + public ExpressionResult checkUserPermissions(Type resourceClass, + Class annotationClass, + String field) { + Supplier expressionSupplier = () -> + expressionBuilder.buildUserCheckFieldExpressions( + resourceClass, + requestScope, + annotationClass, + field); + + return checkOnlyUserPermissions( + resourceClass, + annotationClass, + Collections.singleton(field), + expressionSupplier); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/executors/AggregationStorePermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/AggregationStorePermissionExecutor.java new file mode 100644 index 0000000000..97fdc690c3 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/AggregationStorePermissionExecutor.java @@ -0,0 +1,170 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.security.executors; + +import com.yahoo.elide.annotation.ReadPermission; +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.security.ChangeSpec; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.security.permissions.expressions.Expression; +import com.yahoo.elide.core.type.Type; +import lombok.extern.slf4j.Slf4j; + +import java.lang.annotation.Annotation; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Permission Executor for all models managed by aggregation datastore. + */ +@Slf4j +public class AggregationStorePermissionExecutor extends AbstractPermissionExecutor { + + public AggregationStorePermissionExecutor(RequestScope requestScope) { + super(log, requestScope); + } + + /** + * Checks user checks for the requested fields. + * expression = (field1Rule OR field2Rule ... OR fieldNRule) + * @param annotationClass annotation class + * @param resource resource + * @param requestedFields the list of requested fields + * @param + * @return ExpressionResult - result of the above any field expression + */ + @Override + public ExpressionResult checkPermission(Class annotationClass, + PersistentResource resource, + Set requestedFields) { + if (!annotationClass.equals(ReadPermission.class)) { + return ExpressionResult.FAIL; + } + + Supplier expressionSupplier = () -> + expressionBuilder.buildUserCheckAnyFieldOnlyExpression( + resource.getResourceType(), + annotationClass, + requestedFields, + requestScope); + + return checkOnlyUserPermissions( + resource.getResourceType(), + annotationClass, + requestedFields, + expressionSupplier); + } + + + /** + * Evaluates user check permission on specific field + * Aggregation Datastore model can only have user checks at field level permission expression. + * @param resource resource + * @param changeSpec changepsec + * @param annotationClass annotation class + * @param field field to check + * @param + * @return + */ + @Override + public ExpressionResult checkSpecificFieldPermissions(PersistentResource resource, + ChangeSpec changeSpec, + Class annotationClass, + String field) { + if (!annotationClass.equals(ReadPermission.class)) { + return ExpressionResult.FAIL; + } + return checkUserPermissions(resource.getResourceType(), annotationClass, field); + } + + /** + * Not supported in aggregation datastore. + * @param resource resource + * @param changeSpec changepsec + * @param annotationClass annotation class + * @param field field to check + * @param + * @return + */ + @Override + public ExpressionResult checkSpecificFieldPermissionsDeferred(PersistentResource resource, + ChangeSpec changeSpec, + Class annotationClass, + String field) { + return checkSpecificFieldPermissions(resource, changeSpec, annotationClass, field); + } + + /** + * Check strictly user permissions on an entity and any of the requested field. Evaluates + * expression = (entityRule AND (field1Rule OR field2Rule ... OR fieldNRule)) + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + */ + @Override + public ExpressionResult checkUserPermissions(Type resourceClass, + Class annotationClass, + Set requestedFields) { + if (!annotationClass.equals(ReadPermission.class)) { + return ExpressionResult.FAIL; + } + + Expression expression = expressionBuilder.buildUserCheckEntityAndAnyFieldExpression( + resourceClass, + annotationClass, + requestedFields, + requestScope); + + // Skips userPermissionCheckCache and directly executes the expression. + // Cache is used by checkPermission() which is called for every resource returned. + // Cache cannot be shared b/w checkUserPermissions and checkPermissions because of difference in computation. + return executeExpressions(expression, annotationClass, Expression.EvaluationMode.USER_CHECKS_ONLY); + } + + /** + * Check strictly user permissions on an entity field. + * + * @param type parameter + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param field The entity field + */ + @Override + public ExpressionResult checkUserPermissions(Type resourceClass, + Class annotationClass, + String field) { + if (!annotationClass.equals(ReadPermission.class)) { + return ExpressionResult.FAIL; + } + + Supplier expressionSupplier = () -> + expressionBuilder.buildUserCheckFieldExpressions( + resourceClass, + requestScope, + annotationClass, + field, + false); + + + return checkOnlyUserPermissions( + resourceClass, + annotationClass, + Collections.singleton(field), + expressionSupplier); + } + + @Override + public Optional getReadPermissionFilter(Type resourceClass, Set requestedFields) { + FilterExpression filterExpression = expressionBuilder.buildEntityFilterExpression(resourceClass, requestScope); + return Optional.ofNullable(filterExpression); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/executors/BypassPermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/BypassPermissionExecutor.java new file mode 100644 index 0000000000..01d88b77f1 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/BypassPermissionExecutor.java @@ -0,0 +1,65 @@ +/* + * 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.security.executors; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.PermissionExecutor; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.type.Type; + +import java.lang.annotation.Annotation; +import java.util.Optional; +import java.util.Set; + +/** + * Permission executor intended to bypass all security checks. I.e. this is effectively a no-op. + */ +public class BypassPermissionExecutor implements PermissionExecutor { + @Override + public ExpressionResult checkPermission(Class annotationClass, + PersistentResource resource, + Set requestedFields) { + return ExpressionResult.PASS; + } + + @Override + public ExpressionResult checkSpecificFieldPermissions( + PersistentResource resource, ChangeSpec changeSpec, Class annotationClass, String field) { + return ExpressionResult.PASS; + } + + @Override + public ExpressionResult checkSpecificFieldPermissionsDeferred( + PersistentResource resource, ChangeSpec changeSpec, Class annotationClass, String field) { + return ExpressionResult.PASS; + } + + @Override + public ExpressionResult checkUserPermissions(Type resourceClass, + Class annotationClass, + Set requestedFields) { + return ExpressionResult.PASS; + } + + @Override + public Optional getReadPermissionFilter(Type resourceClass, Set requestedFields) { + return Optional.empty(); + } + + @Override + public ExpressionResult checkUserPermissions(Type resourceClass, + Class annotationClass, + String field) { + return ExpressionResult.PASS; + } + + @Override + public void executeCommitChecks() { + + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/executors/MultiplexPermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/MultiplexPermissionExecutor.java new file mode 100644 index 0000000000..bb4635c3b6 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/MultiplexPermissionExecutor.java @@ -0,0 +1,120 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.security.executors; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.ForbiddenAccessException; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.PermissionExecutor; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.type.Type; +import lombok.AllArgsConstructor; + +import java.lang.annotation.Annotation; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * MultiplexPermissionExecutor manages Model to permssion executor mapping. + * All method call to multiple permission executor will be delegated + * to the underlying permission executor based on resource type. + */ +@AllArgsConstructor +public class MultiplexPermissionExecutor implements PermissionExecutor { + + private Map, PermissionExecutor> permissionExecutorMap; + private PermissionExecutor defaultPermissionExecutor; + private EntityDictionary dictionary; + + public PermissionExecutor getPermissionExecutor(Type cls) { + return permissionExecutorMap.getOrDefault(dictionary.lookupBoundClass(cls), defaultPermissionExecutor); + } + + @Override + public ExpressionResult checkPermission(Class annotationClass, + PersistentResource resource, + Set requestedFields) { + return getPermissionExecutor(resource.getResourceType()) + .checkPermission(annotationClass, resource, requestedFields); + } + + @Override + public ExpressionResult checkSpecificFieldPermissions(PersistentResource resource, + ChangeSpec changeSpec, + Class annotationClass, + String field) { + return getPermissionExecutor(resource.getResourceType()) + .checkSpecificFieldPermissions(resource, changeSpec, annotationClass, field); + } + + @Override + public ExpressionResult checkSpecificFieldPermissionsDeferred(PersistentResource resource, + ChangeSpec changeSpec, + Class annotationClass, + String field) { + return getPermissionExecutor(resource.getResourceType()) + .checkSpecificFieldPermissionsDeferred(resource, changeSpec, annotationClass, field); + } + + @Override + public ExpressionResult checkUserPermissions(Type resourceClass, + Class annotationClass, + Set requestedFields) { + return getPermissionExecutor(resourceClass) + .checkUserPermissions(resourceClass, annotationClass, requestedFields); + } + + @Override + public ExpressionResult checkUserPermissions(Type resourceClass, + Class annotationClass, + String field) { + return getPermissionExecutor(resourceClass) + .checkUserPermissions(resourceClass, annotationClass, field); + } + + @Override + public Optional getReadPermissionFilter(Type resourceClass, Set requestedFields) { + return getPermissionExecutor(resourceClass).getReadPermissionFilter(resourceClass, requestedFields); + } + + @Override + public void executeCommitChecks() { + defaultPermissionExecutor.executeCommitChecks(); + permissionExecutorMap.values().forEach(PermissionExecutor::executeCommitChecks); + } + + @Override + public void logCheckStats() { + defaultPermissionExecutor.logCheckStats(); + permissionExecutorMap.values().forEach(PermissionExecutor::logCheckStats); + } + + @Override + public boolean isVerbose() { + return defaultPermissionExecutor.isVerbose(); + } + + @Override + public ExpressionResult evaluateFilterJoinUserChecks(PersistentResource resource, + FilterPredicate filterPredicate) { + return getPermissionExecutor(resource.getResourceType()) + .evaluateFilterJoinUserChecks(resource, filterPredicate); + } + + @Override + public ExpressionResult handleFilterJoinReject(FilterPredicate filterPredicate, + Path.PathElement pathElement, + ForbiddenAccessException reason) { + return getPermissionExecutor(pathElement.getType()) + .handleFilterJoinReject(filterPredicate, pathElement, reason); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/executors/VerbosePermissionExecutor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/VerbosePermissionExecutor.java similarity index 92% rename from elide-core/src/main/java/com/yahoo/elide/security/executors/VerbosePermissionExecutor.java rename to elide-core/src/main/java/com/yahoo/elide/core/security/executors/VerbosePermissionExecutor.java index 4f32b1db32..e835274be8 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/executors/VerbosePermissionExecutor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/executors/VerbosePermissionExecutor.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.security.executors; +package com.yahoo.elide.core.security.executors; import com.yahoo.elide.core.RequestScope; diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/ExpressionResult.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/ExpressionResult.java similarity index 93% rename from elide-core/src/main/java/com/yahoo/elide/security/permissions/ExpressionResult.java rename to elide-core/src/main/java/com/yahoo/elide/core/security/permissions/ExpressionResult.java index cd997ab220..2a2357694f 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/ExpressionResult.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/ExpressionResult.java @@ -3,11 +3,10 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.security.permissions; - -import org.fusesource.jansi.Ansi; +package com.yahoo.elide.core.security.permissions; import static org.fusesource.jansi.Ansi.ansi; +import org.fusesource.jansi.Ansi; /** * Expression results. diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/ExpressionResultCache.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/ExpressionResultCache.java similarity index 79% rename from elide-core/src/main/java/com/yahoo/elide/security/permissions/ExpressionResultCache.java rename to elide-core/src/main/java/com/yahoo/elide/core/security/permissions/ExpressionResultCache.java index 7ed2123a82..46f8e3207c 100644 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/ExpressionResultCache.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/ExpressionResultCache.java @@ -3,10 +3,10 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.security.permissions; +package com.yahoo.elide.core.security.permissions; -import com.yahoo.elide.security.PersistentResource; -import com.yahoo.elide.security.checks.Check; +import com.yahoo.elide.core.security.PersistentResource; +import com.yahoo.elide.core.security.checks.Check; import java.util.HashMap; import java.util.IdentityHashMap; @@ -29,12 +29,8 @@ public boolean hasStoredResultFor(Class checkClass, PersistentR } public void putResultFor(Class checkClass, PersistentResource resource, ExpressionResult result) { - Map cache = computedResults.get(checkClass); - if (cache == null) { - cache = new IdentityHashMap<>(); - computedResults.put(checkClass, cache); - } - + Map cache = computedResults.computeIfAbsent(checkClass, + unused -> new IdentityHashMap<>()); cache.put(resource, result); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionCondition.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionCondition.java new file mode 100644 index 0000000000..68125bf21a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionCondition.java @@ -0,0 +1,127 @@ +/* + * 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.security.permissions; + +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.UpdatePermission; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.PersistentResource; +import com.yahoo.elide.core.type.Type; +import com.google.common.collect.ImmutableMap; +import lombok.Getter; + +import java.lang.annotation.Annotation; +import java.util.Optional; + +/** + * Describes the state when a permission is evaluated. + */ +public class PermissionCondition { + @Getter final Class permission; + @Getter final Type entityClass; + @Getter final Optional resource; + @Getter final Optional changes; + @Getter final Optional field; + + private static final ImmutableMap, String> PERMISSION_TO_NAME = ImmutableMap.of( + ReadPermission.class, "READ", + UpdatePermission.class, "UPDATE", + DeletePermission.class, "DELETE", + CreatePermission.class, "CREATE", + NonTransferable.class, "NO TRANSFER"); + + /** + * This function attempts to create the appropriate {@link PermissionCondition} based on parameters that may or may + * not be null. This is a temporary workaround given that the caller functions duplicate data in their + * signatures and pass nulls. The calling code needs to be cleaned up - and then this function can be disposed of. + * + * @param permission the permission to inspect + * @param resource the resource to evalute the permission on + * @param field the name of the field to be checked + * @param changes the changes that happened + * @return a {@link PermissionCondition} if one can be created, null otherwise + */ + public static PermissionCondition create( + Class permission, + PersistentResource resource, + String field, + ChangeSpec changes + ) { + if (resource != null) { + if (changes != null) { + /* Tests do this - not sure if this is real */ + if (field != null && changes.getFieldName() == null) { + return new PermissionCondition(permission, resource, field); + } + return new PermissionCondition(permission, resource, changes); + } + if (field == null) { + return new PermissionCondition(permission, resource); + } + return new PermissionCondition(permission, resource, field); + } + throw new IllegalArgumentException("Resource cannot be null"); + } + + PermissionCondition(Class permission, PersistentResource resource) { + this.permission = permission; + this.resource = Optional.of(resource); + this.entityClass = resource.getResourceType(); + this.changes = Optional.empty(); + this.field = Optional.empty(); + } + + PermissionCondition(Class permission, PersistentResource resource, ChangeSpec changes) { + this.permission = permission; + this.resource = Optional.of(resource); + this.entityClass = resource.getResourceType(); + this.changes = Optional.of(changes); + this.field = Optional.ofNullable(changes.getFieldName()); + } + + PermissionCondition(Class permission, Type entityClass) { + this.permission = permission; + this.resource = Optional.empty(); + this.entityClass = entityClass; + this.changes = Optional.empty(); + this.field = Optional.empty(); + } + + PermissionCondition(Class permission, Type entityClass, String field) { + this.permission = permission; + this.resource = Optional.empty(); + this.entityClass = entityClass; + this.changes = Optional.empty(); + this.field = Optional.of(field); + } + + PermissionCondition(Class permission, PersistentResource resource, String field) { + this.permission = permission; + this.resource = Optional.of(resource); + this.entityClass = resource.getResourceType(); + this.changes = Optional.empty(); + this.field = Optional.of(field); + } + + @Override + public String toString() { + Object entity = ((Optional) resource).orElse(entityClass); + + String withClause = changes.map(c -> String.format("WITH CHANGES %s", c)) + .orElseGet(() -> field.map(f -> String.format("WITH FIELD %s", f)) + .orElse("")); + + return String.format( + "%s PERMISSION WAS INVOKED ON %s %s", + PERMISSION_TO_NAME.get(permission), + entity, + withClause); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionExpressionBuilder.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionExpressionBuilder.java new file mode 100644 index 0000000000..ae69fe7f89 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionExpressionBuilder.java @@ -0,0 +1,505 @@ +/* + * 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.security.permissions; + +import static com.yahoo.elide.core.security.permissions.expressions.Expression.Results.FAILURE; +import static com.yahoo.elide.core.security.visitors.PermissionToFilterExpressionVisitor.FALSE_USER_CHECK_EXPRESSION; +import static com.yahoo.elide.core.security.visitors.PermissionToFilterExpressionVisitor.NO_EVALUATION_EXPRESSION; +import static com.yahoo.elide.core.security.visitors.PermissionToFilterExpressionVisitor.TRUE_USER_CHECK_EXPRESSION; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.expression.OrFilterExpression; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.security.permissions.expressions.AndExpression; +import com.yahoo.elide.core.security.permissions.expressions.AnyFieldExpression; +import com.yahoo.elide.core.security.permissions.expressions.CheckExpression; +import com.yahoo.elide.core.security.permissions.expressions.Expression; +import com.yahoo.elide.core.security.permissions.expressions.OrExpression; +import com.yahoo.elide.core.security.permissions.expressions.SpecificFieldExpression; +import com.yahoo.elide.core.security.visitors.PermissionExpressionNormalizationVisitor; +import com.yahoo.elide.core.security.visitors.PermissionExpressionVisitor; +import com.yahoo.elide.core.security.visitors.PermissionToFilterExpressionVisitor; +import com.yahoo.elide.core.type.Type; +import org.antlr.v4.runtime.tree.ParseTree; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Expression builder to parse annotations and express the result as the Expression AST. + */ +public class PermissionExpressionBuilder { + private final EntityDictionary entityDictionary; + private final ExpressionResultCache cache; + + private static final Expression SUCCESSFUL_EXPRESSION = OrExpression.SUCCESSFUL_EXPRESSION; + public static final Expression FAIL_EXPRESSION = OrExpression.FAILURE_EXPRESSION; + + /** + * Constructor. + * + * @param cache Cache + * @param dictionary EntityDictionary + */ + public PermissionExpressionBuilder(ExpressionResultCache cache, EntityDictionary dictionary) { + this.cache = cache; + this.entityDictionary = dictionary; + } + + /** + * Build an expression that checks a specific field. + * + * @param resource Resource + * @param annotationClass Annotation class + * @param field Field + * @param changeSpec Change spec + * @param Type parameter + * @return Commit and operation expressions + */ + public Expression buildSpecificFieldExpressions(final PersistentResource resource, + final Class annotationClass, + final String field, + final ChangeSpec changeSpec) { + + Type resourceClass = resource.getResourceType(); + if (!entityDictionary.entityHasChecksForPermission(resourceClass, annotationClass)) { + return SUCCESSFUL_EXPRESSION; + } + + final Function leafBuilderFn = leafBuilder(resource, changeSpec); + + final Function, Expression> buildExpressionFn = + (checkFn) -> buildSpecificFieldExpression( + PermissionCondition.create(annotationClass, resource, field, changeSpec), + checkFn, + true + ); + + return buildExpressionFn.apply(leafBuilderFn); + } + + /** + * Build an expression that checks any field on a bean. + * + * @param resource Resource + * @param annotationClass annotation class + * @param changeSpec change spec + * @param type parameter + * @return Commit and operation expressions + */ + public Expression buildAnyFieldExpressions(final PersistentResource resource, + final Class annotationClass, + Set requestedFields, + final ChangeSpec changeSpec) { + + + Type resourceClass = resource.getResourceType(); + if (!entityDictionary.entityHasChecksForPermission(resourceClass, annotationClass)) { + return SUCCESSFUL_EXPRESSION; + } + + final Function leafBuilderFn = leafBuilder(resource, changeSpec); + + final Function, Expression> expressionFunction = + (checkFn) -> buildAnyFieldExpression( + PermissionCondition.create(annotationClass, resource, (String) null, changeSpec), + checkFn, + requestedFields, + resource.getRequestScope() + ); + + return expressionFunction.apply(leafBuilderFn); + } + + /** + * Build an expression that strictly evaluates UserCheck's and ignores other checks for a specific field. + *

+ * NOTE: This method returns _NO_ commit checks. + * + * @param resourceClass Resource Class + * @param scope The request scope. + * @param annotationClass Annotation class + * @param field Field to check (if null only check entity-level) + * @param type parameter + * @return User check expression to evaluate + */ + public Expression buildUserCheckFieldExpressions(final Type resourceClass, + final RequestScope scope, + final Class annotationClass, + final String field) { + return buildUserCheckFieldExpressions(resourceClass, scope, annotationClass, field, true); + } + + /** + * Build an expression that strictly evaluates UserCheck's and ignores other checks for a specific field. + *

+ * NOTE: This method returns _NO_ commit checks. + * + * @param resourceClass Resource Class + * @param scope The request scope. + * @param annotationClass Annotation class + * @param field Field to check (if null only check entity-level) + * @param includeEntityPermission whether entity permission needs to be evaluated in the absence of field permission + * @param type parameter + * @return User check expression to evaluate + */ + public Expression buildUserCheckFieldExpressions(final Type resourceClass, + final RequestScope scope, + final Class annotationClass, + final String field, + final boolean includeEntityPermission) { + if (!entityDictionary.entityHasChecksForPermission(resourceClass, annotationClass)) { + return SUCCESSFUL_EXPRESSION; + } + + final Function leafBuilderFn = (check) -> + new CheckExpression(check, null, scope, null, cache); + + return buildSpecificFieldExpression(new PermissionCondition(annotationClass, resourceClass, field), + leafBuilderFn, includeEntityPermission); + } + + /** + * Build an expression that strictly evaluates UserCheck's and ignores other checks for an entity. + * expression = (field1Rule OR field2Rule ... OR fieldNRule) + *

+ * NOTE: This method returns _NO_ commit checks. + * + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param requestScope Request scope + * @param type parameter + * @return User check expression to evaluate + */ + public Expression buildUserCheckAnyExpression(final Type resourceClass, + final Class annotationClass, + Set requestedFields, + final RequestScope requestScope) { + + final Function leafBuilderFn = (check) -> + new CheckExpression(check, null, requestScope, null, cache); + + return buildAnyFieldExpression( + new PermissionCondition(annotationClass, resourceClass), leafBuilderFn, + requestedFields, requestScope); + } + + /** + * Build an expression that strictly evaluates UserCheck's and ignores other checks for an entity. + * expression = (field1Rule OR field2Rule ... OR fieldNRule) + *

+ * NOTE: This method returns _NO_ commit checks. + * + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param requestScope Request scope + * @param type parameter + * @return User check expression to evaluate + */ + public Expression buildUserCheckAnyFieldOnlyExpression(final Type resourceClass, + final Class annotationClass, + Set requestedFields, + final RequestScope requestScope) { + + final Function leafBuilderFn = (check) -> + new CheckExpression(check, null, requestScope, null, cache); + + return buildAnyFieldOnlyExpression( + new PermissionCondition(annotationClass, resourceClass), leafBuilderFn, requestedFields); + } + + /** + * Build an expression that strictly evaluates UserCheck's and ignores other checks for an entity. + * expression = (entityRule AND (field1Rule OR field2Rule ... OR fieldNRule)) + *

+ * NOTE: This method returns _NO_ commit checks. + * + * @param resourceClass Resource class + * @param annotationClass Annotation class + * @param scope Request scope + * @param type parameter + * @return User check expression to evaluate + */ + public Expression buildUserCheckEntityAndAnyFieldExpression(final Type resourceClass, + final Class annotationClass, + Set requestedFields, + final RequestScope scope) { + + final Function leafBuilderFn = (check) -> + new CheckExpression(check, null, scope, null, cache); + + ParseTree classPermissions = entityDictionary.getPermissionsForClass(resourceClass, annotationClass); + Expression entityExpression = normalizedExpressionFromParseTree(classPermissions, leafBuilderFn); + + Expression anyFieldExpression = buildAnyFieldOnlyExpression( + new PermissionCondition(annotationClass, resourceClass), leafBuilderFn, + requestedFields); + + if (entityExpression == null) { + return anyFieldExpression; + } + + return new AndExpression(entityExpression, anyFieldExpression); + } + + /** + * Builder for specific field expressions. + * + * @param condition The condition which triggered this permission expression check + * @param checkFn Operation check function + * @param includeEntityPermission whether entity permission needs to be evaluated in the absence of field permission + * @return Expressions representing specific field + */ + private Expression buildSpecificFieldExpression(final PermissionCondition condition, + final Function checkFn, + boolean includeEntityPermission) { + Type resourceClass = condition.getEntityClass(); + Class annotationClass = condition.getPermission(); + String field = condition.getField().orElse(null); + + ParseTree fieldPermissions = entityDictionary.getPermissionsForField(resourceClass, field, annotationClass); + + if (includeEntityPermission) { + ParseTree classPermissions = entityDictionary.getPermissionsForClass(resourceClass, annotationClass); + return new SpecificFieldExpression(condition, + normalizedExpressionFromParseTree(classPermissions, checkFn), + normalizedExpressionFromParseTree(fieldPermissions, checkFn) + ); + } + return new SpecificFieldExpression(condition, + null, + normalizedExpressionFromParseTree(fieldPermissions, checkFn) + ); + } + + /** + * Build an expression representing any field on an entity. + * + * @param condition The condition which triggered this permission expression check + * @param checkFn check function + * @param scope RequestScope + * @param requestedFields The list of requested fields + * @return Expressions + */ + private Expression buildAnyFieldExpression(final PermissionCondition condition, + final Function checkFn, + final Set requestedFields, + final RequestScope scope) { + + Type resourceClass = condition.getEntityClass(); + Class annotationClass = condition.getPermission(); + + ParseTree classPermissions = entityDictionary.getPermissionsForClass(resourceClass, annotationClass); + Expression entityExpression = normalizedExpressionFromParseTree(classPermissions, checkFn); + + OrExpression allFieldsExpression = new OrExpression(FAILURE, null); + List fields = entityDictionary.getAllExposedFields(resourceClass); + + boolean entityExpressionUsed = false; + boolean fieldExpressionUsed = false; + + for (String field : fields) { + if (requestedFields != null && !requestedFields.contains(field)) { + continue; + } + + ParseTree fieldPermissions = entityDictionary.getPermissionsForField(resourceClass, field, annotationClass); + Expression fieldExpression = normalizedExpressionFromParseTree(fieldPermissions, checkFn); + + if (fieldExpression == null) { + + if (entityExpressionUsed) { + continue; + } + + if (entityExpression == null) { + //One field had no permissions set - so we allow the action. + return SUCCESSFUL_EXPRESSION; + } + + fieldExpression = entityExpression; + entityExpressionUsed = true; + } else { + fieldExpressionUsed = true; + } + + allFieldsExpression = new OrExpression(allFieldsExpression, fieldExpression); + } + + if (! fieldExpressionUsed) { + //If there are no permissions, allow access... + if (entityExpression == null) { + return SUCCESSFUL_EXPRESSION; + } + return new AnyFieldExpression(condition, entityExpression); + } + + return new AnyFieldExpression(condition, allFieldsExpression); + } + + /** + * Builds disjunction of permission expression of all requested fields. + * If the field permission is null, then return default SUCCESSFUL_EXPRESSION. + * expression = (field1Rule OR field2Rule ... OR fieldNRule) + * @param condition The condition which triggered this permission expression check + * @param checkFn check function + * @param requestedFields The list of requested fields + * @return Expression + */ + private Expression buildAnyFieldOnlyExpression(final PermissionCondition condition, + final Function checkFn, + final Set requestedFields) { + Type resourceClass = condition.getEntityClass(); + Class annotationClass = condition.getPermission(); + + OrExpression allFieldsExpression = new OrExpression(FAILURE, null); + List fields = entityDictionary.getAllExposedFields(resourceClass); + + boolean fieldExpressionUsed = false; + + for (String field : fields) { + if (requestedFields != null && !requestedFields.contains(field)) { + continue; + } + + ParseTree fieldPermissions = entityDictionary.getPermissionsForField(resourceClass, field, annotationClass); + Expression fieldExpression = normalizedExpressionFromParseTree(fieldPermissions, checkFn); + + if (fieldExpression == null) { + return SUCCESSFUL_EXPRESSION; + } + fieldExpressionUsed = true; + + allFieldsExpression = new OrExpression(allFieldsExpression, fieldExpression); + } + + if (!fieldExpressionUsed) { + return SUCCESSFUL_EXPRESSION; + } + + return new AnyFieldExpression(condition, allFieldsExpression); + } + + + /** + * Build an expression representing any field on an entity. + * + * @param forType Resource class + * @param requestScope requestScope + * @return Expressions + */ + public FilterExpression buildAnyFieldFilterExpression( + Type forType, + RequestScope requestScope, + Set requestedFields + ) { + + Class annotationClass = ReadPermission.class; + ParseTree classPermissions = entityDictionary.getPermissionsForClass(forType, annotationClass); + FilterExpression entityFilter = filterExpressionFromParseTree(classPermissions, forType, requestScope); + + //case where the permissions does not have ANY filterExpressionCheck + if (entityFilter == FALSE_USER_CHECK_EXPRESSION + || entityFilter == NO_EVALUATION_EXPRESSION + || entityFilter == TRUE_USER_CHECK_EXPRESSION) { + entityFilter = null; + } + + FilterExpression allFieldsFilterExpression = entityFilter; + List fields = entityDictionary.getAllExposedFields(forType).stream() + .filter(field -> requestedFields == null || requestedFields.contains(field)) + .collect(Collectors.toList()); + + for (String field : fields) { + ParseTree fieldPermissions = entityDictionary.getPermissionsForField(forType, field, annotationClass); + FilterExpression fieldExpression = filterExpressionFromParseTree(fieldPermissions, forType, requestScope); + + if (fieldExpression == null && entityFilter == null) { + // When the class FilterExpression and the field FilterExpression are null because at least + // this field will be visible across all instances + return null; + } + + if (fieldExpression == null || fieldExpression == FALSE_USER_CHECK_EXPRESSION) { + // When the expression is null no permissions have been defined for this field + // When the expression is FALSE_USER_CHECK_EXPRESSION this field is not accessible to the user + // In either case this field is not useful for filtering when loading records + continue; + } + + if (fieldExpression == NO_EVALUATION_EXPRESSION || fieldExpression == TRUE_USER_CHECK_EXPRESSION) { + // When the expression is NO_EVALUATION_EXPRESSION we must evaluate check in memory across all instances + // When the expression is TRUE_USER_CHECK_EXPRESSION all records can be loaded + return null; + } + + if (allFieldsFilterExpression != null) { + allFieldsFilterExpression = new OrFilterExpression(allFieldsFilterExpression, fieldExpression); + } else { + allFieldsFilterExpression = fieldExpression; + } + } + + return allFieldsFilterExpression; + } + + /** + * Build a filter expression for entity permission alone + * @param forType Resource class + * @param requestScope Request Scope + * @return + */ + public FilterExpression buildEntityFilterExpression(Type forType, RequestScope requestScope) { + ParseTree classPermissions = entityDictionary.getPermissionsForClass(forType, ReadPermission.class); + FilterExpression entityFilter = filterExpressionFromParseTree(classPermissions, forType, requestScope); + //case where the permissions does not have ANY filterExpressionCheck + if (entityFilter == FALSE_USER_CHECK_EXPRESSION + || entityFilter == NO_EVALUATION_EXPRESSION + || entityFilter == TRUE_USER_CHECK_EXPRESSION) { + return null; + } + return entityFilter; + } + + private Expression normalizedExpressionFromParseTree(ParseTree permissions, Function checkFn) { + if (permissions == null) { + return null; + } + + return permissions + .accept(new PermissionExpressionVisitor(entityDictionary, checkFn)) + .accept(new PermissionExpressionNormalizationVisitor()); + } + + private FilterExpression filterExpressionFromParseTree(ParseTree permissions, Type type, RequestScope scope) { + if (permissions == null) { + return null; + } + final Function checkFn = (check) -> + new CheckExpression(check, null, scope, null, cache); + + return normalizedExpressionFromParseTree(permissions, checkFn) + .accept(new PermissionToFilterExpressionVisitor(entityDictionary, scope, type)); + } + + private Function leafBuilder(PersistentResource resource, ChangeSpec changeSpec) { + final Function leafBuilderFn = (check) -> new CheckExpression( + check, + resource, + resource.getRequestScope(), + changeSpec, + cache + ); + return leafBuilderFn; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AndExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AndExpression.java new file mode 100644 index 0000000000..23f78d47ab --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AndExpression.java @@ -0,0 +1,69 @@ +/* + * 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.security.permissions.expressions; + +import static com.yahoo.elide.core.security.permissions.ExpressionResult.DEFERRED; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.FAIL; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.PASS; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import lombok.Getter; + +/** + * Representation for an "And" expression. + */ +public class AndExpression implements Expression { + @Getter + private final Expression left; + @Getter + private final Expression right; + + /** + * Constructor. + * + * @param left Left expression + * @param right Right expression + */ + public AndExpression(final Expression left, final Expression right) { + this.left = left; + this.right = right; + } + + @Override + public ExpressionResult evaluate(EvaluationMode mode) { + ExpressionResult leftStatus = left.evaluate(mode); + + // Short-circuit + if (leftStatus == FAIL) { + return leftStatus; + } + + ExpressionResult rightStatus = (right == null) ? PASS : right.evaluate(mode); + + if (rightStatus == FAIL) { + return rightStatus; + } + + if (leftStatus == PASS && rightStatus == PASS) { + return PASS; + } + + return DEFERRED; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitAndExpression(this); + } + + @Override + public String toString() { + if (right == null) { + return String.format("%s", left); + + } + return String.format("(%s) AND (%s)", left, right); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AnyFieldExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AnyFieldExpression.java new file mode 100644 index 0000000000..c34d1123b6 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AnyFieldExpression.java @@ -0,0 +1,43 @@ +/* + * 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.security.permissions.expressions; + +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.security.permissions.PermissionCondition; + +/** + * This check determines if an entity is accessible to the current user. + * + * An entity is considered to be accessible if there exists an annotation at _any_ level of the object that + * Grants access. This means that if access is permitted to any field of the object then the object + * is accessible, regardless of what any class or package level permissions would permit. + */ +public class AnyFieldExpression implements Expression { + private final Expression expression; + private final PermissionCondition condition; + + public AnyFieldExpression(final PermissionCondition condition, + final Expression expression) { + this.condition = condition; + this.expression = expression; + } + + @Override + public ExpressionResult evaluate(EvaluationMode mode) { + return expression.evaluate(mode); + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitExpression(this); + } + + @Override + public String toString() { + return String.format("%s FOR EXPRESSION [%s]", + condition, expression); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/CheckExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/CheckExpression.java new file mode 100644 index 0000000000..82369a51b7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/CheckExpression.java @@ -0,0 +1,139 @@ +/* + * 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.security.permissions.expressions; + +import static com.yahoo.elide.core.security.permissions.ExpressionResult.DEFERRED; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.FAIL; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.PASS; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.UNEVALUATED; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.security.checks.OperationCheck; +import com.yahoo.elide.core.security.checks.UserCheck; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.security.permissions.ExpressionResultCache; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.Optional; + +/** + * An expression in the security evaluation AST that wraps an actual check. + */ +@Slf4j +public class CheckExpression implements Expression { + + @Getter + protected final Check check; + protected final PersistentResource resource; + protected final RequestScope requestScope; + protected final ExpressionResultCache cache; + protected ExpressionResult result; + + private final Optional changeSpec; + + /** + * Constructor. + * + * @param check The check to be evaluated by this expression + * @param resource The resource to pass to the check + * @param requestScope The requestScope to pass to the check + * @param changeSpec The changeSpec to pass to the check + * @param cache The cache of previous expression results + */ + public CheckExpression(final Check check, + final PersistentResource resource, + final RequestScope requestScope, + final ChangeSpec changeSpec, + final ExpressionResultCache cache) { + this.check = check; + this.requestScope = requestScope; + this.changeSpec = Optional.ofNullable(changeSpec); + this.cache = cache; + this.result = UNEVALUATED; + + // UserCheck does not use resource + this.resource = (check instanceof UserCheck) ? null : resource; + } + + @Override + public ExpressionResult evaluate(EvaluationMode mode) { + log.trace("Evaluating check: {} in mode {}", check, mode); + + /* Result evaluation is sticky once evaluated to PASS or FAIL */ + if (result == PASS || result == FAIL) { + return result; + } + + if (mode == EvaluationMode.USER_CHECKS_ONLY && ! (check instanceof UserCheck)) { + result = DEFERRED; + return result; + } + + if (mode == EvaluationMode.INLINE_CHECKS_ONLY && (resource != null && resource.isNewlyCreated())) { + result = DEFERRED; + return result; + } + + if (mode == EvaluationMode.INLINE_CHECKS_ONLY && check.runAtCommit()) { + result = DEFERRED; + return result; + } + + // If we have a valid change spec, do not cache the result or look for a cached result. + if (changeSpec.isPresent()) { + log.trace("-- Check has changespec: {}", changeSpec); + result = computeCheck(); + log.trace("-- Check returned with result: {}", result); + return result; + } + + // Otherwise, search the cache and use value if found. Otherwise, evaluate and add it to the cache. + log.trace("-- Check does NOT have changespec"); + Class checkClass = check.getClass(); + + if (cache.hasStoredResultFor(checkClass, resource)) { + result = cache.getResultFor(checkClass, resource); + } else { + result = computeCheck(); + cache.putResultFor(checkClass, resource, result); + log.trace("-- Check computed result: {}", result); + } + + log.trace("-- Check returned with result: {}", result); + return result; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitCheckExpression(this); + } + + /** + * Actually compute the result of the check without caching concerns. + * + * @return Expression result from the check. + */ + private ExpressionResult computeCheck() { + Object entity = (resource == null) ? null : resource.getObject(); + + if (check instanceof UserCheck) { + result = ((UserCheck) check).ok(requestScope.getUser()) ? PASS : FAIL; + } else { + result = ((OperationCheck) check).ok(entity, requestScope, changeSpec) ? PASS : FAIL; + } + return result; + } + + @Override + public String toString() { + EntityDictionary dictionary = ((com.yahoo.elide.core.RequestScope) requestScope).getDictionary(); + return String.format("(%s %s)", dictionary.getCheckIdentifier(check.getClass()), result); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/Expression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/Expression.java new file mode 100644 index 0000000000..7ea6e20d29 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/Expression.java @@ -0,0 +1,73 @@ +/* + * 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.security.permissions.expressions; + +import static org.fusesource.jansi.Ansi.ansi; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import org.fusesource.jansi.Ansi; + +/** + * Interface describing an expression. + */ +public interface Expression { + + /** + * Different modes of evaluating security expressions. + */ + public enum EvaluationMode { + USER_CHECKS_ONLY, /* Only the user checks are evaluated */ + INLINE_CHECKS_ONLY, /* Only the inline checks are evaluated */ + ALL_CHECKS /* Everything is evaluated */ + } + + /** + * Evaluate an expression. + * + * @param mode mode for evaluating security expressions + * @return The result of the fully evaluated expression. + */ + ExpressionResult evaluate(EvaluationMode mode); + + public T accept(ExpressionVisitor visitor); + + /** + * Static Expressions that return PASS or FAIL. + */ + public static class Results { + public static final Expression SUCCESS = new Expression() { + @Override + public ExpressionResult evaluate(EvaluationMode ignored) { + return ExpressionResult.PASS; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitExpression(this); + } + + @Override + public String toString() { + return ansi().fg(Ansi.Color.GREEN).a("SUCCESS").reset().toString(); + } + }; + public static final Expression FAILURE = new Expression() { + @Override + public ExpressionResult evaluate(EvaluationMode ignored) { + return ExpressionResult.FAIL; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitExpression(this); + } + + @Override + public String toString() { + return ansi().fg(Ansi.Color.RED).a("FAILURE").reset().toString(); + } + }; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/ExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/ExpressionVisitor.java new file mode 100644 index 0000000000..a3d48fa6bd --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/ExpressionVisitor.java @@ -0,0 +1,19 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.security.permissions.expressions; + +/** + * Visitor which walks the permission expression abstract syntax tree. + * @param The return type of the visitor + */ +public interface ExpressionVisitor { + T visitExpression(Expression expression); + T visitCheckExpression(CheckExpression checkExpression); + T visitAndExpression(AndExpression andExpression); + T visitOrExpression(OrExpression orExpression); + T visitNotExpression(NotExpression notExpression); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/NotExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/NotExpression.java new file mode 100644 index 0000000000..4402489727 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/NotExpression.java @@ -0,0 +1,55 @@ +/* + * 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.security.permissions.expressions; + +import static com.yahoo.elide.core.security.permissions.ExpressionResult.DEFERRED; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.FAIL; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.PASS; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import lombok.Getter; + +/** + * Representation of a "not" expression. + */ +public class NotExpression implements Expression { + @Getter + private final Expression logical; + + /** + * Constructor. + * + * @param logical Unary expression + */ + public NotExpression(final Expression logical) { + this.logical = logical; + } + + + @Override + public ExpressionResult evaluate(EvaluationMode mode) { + ExpressionResult result = logical.evaluate(mode); + + if (result == FAIL) { + return PASS; + } + + if (result == PASS) { + return FAIL; + } + + return DEFERRED; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitNotExpression(this); + } + + @Override + public String toString() { + return String.format("NOT (%s)", logical); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/OrExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/OrExpression.java new file mode 100644 index 0000000000..b6b2643f9e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/OrExpression.java @@ -0,0 +1,71 @@ +/* + * 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.security.permissions.expressions; + +import static com.yahoo.elide.core.security.permissions.ExpressionResult.DEFERRED; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.FAIL; +import static com.yahoo.elide.core.security.permissions.ExpressionResult.PASS; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import lombok.Getter; + +/** + * Representation of an "or" expression. + */ +public class OrExpression implements Expression { + @Getter + private final Expression left; + @Getter + private final Expression right; + + public static final OrExpression SUCCESSFUL_EXPRESSION = new OrExpression(Results.SUCCESS, null); + public static final OrExpression FAILURE_EXPRESSION = new OrExpression(Results.FAILURE, null); + + /** + * Constructor. + * + * @param left Left expression + * @param right Right expression. + */ + public OrExpression(final Expression left, final Expression right) { + this.left = left; + this.right = right; + } + + @Override + public ExpressionResult evaluate(EvaluationMode mode) { + ExpressionResult leftResult = left.evaluate(mode); + + // Short-circuit + if (leftResult == PASS) { + return PASS; + } + + ExpressionResult rightResult = (right == null) ? leftResult : right.evaluate(mode); + + if (leftResult == FAIL && rightResult == FAIL) { + return leftResult; + } + + if (rightResult == PASS) { + return PASS; + } + + return DEFERRED; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitOrExpression(this); + } + + @Override + public String toString() { + if (right == null || right.equals(Results.FAILURE)) { + return String.format("%s", left); + } + return String.format("(%s) OR (%s)", left, right); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/SpecificFieldExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/SpecificFieldExpression.java new file mode 100644 index 0000000000..802c68c9eb --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/SpecificFieldExpression.java @@ -0,0 +1,59 @@ +/* + * 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.security.permissions.expressions; + +import static com.yahoo.elide.core.security.permissions.ExpressionResult.PASS; +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import com.yahoo.elide.core.security.permissions.PermissionCondition; +import lombok.Getter; + +import java.util.Optional; + +/** + * Expression for joining specific fields. + * + * That is, this evaluates security while giving precedence to the annotation on a particular field over + * the annotation at the entity- or package-level. + */ +public class SpecificFieldExpression implements Expression { + private final Expression entityExpression; + private final Optional fieldExpression; + @Getter private final PermissionCondition condition; + + public SpecificFieldExpression(final PermissionCondition condition, + final Expression entityExpression, + final Expression fieldExpression) { + this.condition = condition; + this.entityExpression = entityExpression; + this.fieldExpression = Optional.ofNullable(fieldExpression); + } + + @Override + public ExpressionResult evaluate(EvaluationMode mode) { + if (!fieldExpression.isPresent()) { + return (entityExpression == null) ? PASS : entityExpression.evaluate(mode); + } + return fieldExpression.get().evaluate(mode); + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitExpression(this); + } + + + @Override + public String toString() { + return fieldExpression + .map(fe -> String.format("%s FOR EXPRESSION [FIELD(%s)]", condition, fe)) + .orElseGet(() -> { + if (entityExpression == null) { + return String.format("%s FOR EXPRESSION []", condition); + } + return String.format("%s FOR EXPRESSION [ENTITY(%s)]", condition, entityExpression); + }); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/CanPaginateVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/CanPaginateVisitor.java new file mode 100644 index 0000000000..3dad96a4e9 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/CanPaginateVisitor.java @@ -0,0 +1,285 @@ +/* + * 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.security.visitors; + +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.security.checks.FilterExpressionCheck; +import com.yahoo.elide.core.security.checks.UserCheck; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.generated.parsers.ExpressionBaseVisitor; +import com.yahoo.elide.generated.parsers.ExpressionParser; +import org.antlr.v4.runtime.tree.ParseTree; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Set; + +/** + * Walks a permission expression to determine if any part of the expression must be evaluated in memory. + * If part of the expression must be evaluated in memory, the data store cannot paginate the result. + * + * Here are some examples of what the expected behavior is for combining user and filter expression checks: + * + * User Check (TRUE) OR Filter Expression (TRUE) + * - Elide will not push the filter predicate to the data store. + * - Elide will allow pagination. + * - The entire result set will be paginated. + * - The entire result set will be returned. + * + * User Check (TRUE) OR Filter Expression (FALSE) + * - Elide will not push the filter predicate to the data store. + * - Elide will allow pagination. + * - The entire result set will be paginated. + * - The entire result set will be returned. + * + * User Check (FALSE) OR Filter Expression (TRUE) + * - Elide will push the filter predicate to the data store. + * - Elide will allow pagination. + * - The filtered result set will be paginated. + * - The filtered result set will be returned. + * + * User Check (FALSE) OR Filter Expression (FALSE) + * - Elide will push the filter predicate to the data store. + * - Elide will allow pagination. + * - The empty result set will be paginated. + * - The empty result set will be returned. + * + * User Check (TRUE) AND Filter Expression (TRUE) + * - Elide will push the filter predicate to the data store. + * - Elide will allow pagination. + * - The filtered result set will be paginated. + * - The filtered result set will be returned. + * + * User Check (TRUE) AND Filter Expression (FALSE) + * - Elide will push the filter predicate to the data store. + * - Elide will allow pagination. + * - The empty result set will be paginated. + * - The empty result set will be returned. + * + * User Check (FALSE) AND Filter Expression (TRUE) + * - Elide will push the filter predicate to the data store. + * - Elide will allow pagination. + * - The filtered result set will be paginated. + * - The empty result set will be returned. + * + * User Check (FALSE) AND Filter Expression (FALSE) + * - Elide will push the filter predicate to the data store. + * - Elide will allow pagination. + * - The empty result set will be paginated. + * - The empty result set will be returned. + * + * More Complex Scenarios: + * + * (User Check (TRUE) OR Filter Expression 1 (FALSE)) AND Filter Expression 2 (TRUE) + * - Elide WILL push the filter predicate to the data store. + * - Elide will allow pagination. + * - The filtered (2) result set will be paginated. + * - The filtered (2) result set will be returned. + * + * (User Check (TRUE) OR Filter Expression 1 (TRUE)) AND Filter Expression 2 (TRUE) + * - Elide WILL push the filter predicate (2) to the data store. + * - Elide will allow pagination. + * - The filtered (2) result set will be paginated. + * - The filtered (2) result set will be returned. + * + * (User Check (FALSE) OR Filter Expression 1 (TRUE)) AND Filter Expression 2 (TRUE) + * - Elide WILL push the filter predicate (1 and 2) to the data store. + * - Elide will allow pagination. + * - The filtered (1 and 2) result set will be paginated. + * - The filtered (1 and 2) result set will be returned. + * + * (User Check (FALSE) OR Filter Expression 1 (FALSE)) AND Filter Expression 2 (TRUE) + * - Elide WILL push the filter predicate (1 and 2) to the data store. + * - Elide will allow pagination. + * - The empty result set will be paginated. + * - The empty result set will be returned. + * + * (User Check (TRUE) AND Filter Expression 1 (FALSE)) OR Filter Expression 2 (TRUE) + * - Elide WILL push the filter predicate (1 or 2) to the data store. + * - Elide will allow pagination. + * - The filtered (2) result set will be paginated. + * - The filtered (2) result set will be returned. + * + * (User Check (TRUE) AND Filter Expression 1 (TRUE)) OR Filter Expression 2 (TRUE) + * - Elide WILL push the filter predicate (1 or 2) to the data store. + * - Elide will allow pagination. + * - The filtered (1 or 2) result set will be paginated. + * - The filtered (1 or 2) result set will be returned. + * + * (User Check (FALSE) AND Filter Expression 1 (TRUE)) OR Filter Expression 2 (TRUE) + * - Elide WILL push the filter predicate (2) to the data store. + * - Elide will allow pagination. + * - The filtered (2) result set will be paginated. + * - The filtered (2) result set will be returned + * + * (User Check (FALSE) AND Filter Expression 1 (FALSE)) OR Filter Expression 2 (TRUE) + * - Elide WILL push the filter predicate (2) to the data store. + * - Elide will allow pagination. + * - The filtered (2) result set will be paginated. + * - The filtered (2) result set will be returned + * + */ +public class CanPaginateVisitor + extends ExpressionBaseVisitor { + + /** + * All states except for CANNOT_PAGINATE allow for pagination. + */ + public enum PaginationStatus { + CAN_PAGINATE, + USER_CHECK_FALSE, + USER_CHECK_TRUE, + CANNOT_PAGINATE + }; + + private final EntityDictionary dictionary; + private final RequestScope scope; + private Set requestedFields; + + + public CanPaginateVisitor(EntityDictionary dictionary, RequestScope scope, Set requestedFields) { + this.dictionary = dictionary; + this.scope = scope; + this.requestedFields = requestedFields; + } + + @Override + public PaginationStatus visitNOT(ExpressionParser.NOTContext ctx) { + PaginationStatus status = visit(ctx.expression()); + if (status == PaginationStatus.USER_CHECK_FALSE) { + return PaginationStatus.USER_CHECK_TRUE; + } + + if (status == PaginationStatus.USER_CHECK_TRUE) { + return PaginationStatus.USER_CHECK_FALSE; + } + + /* + * Pagination status really only depends on whether the check runs in memory or in the DB. NOT has not bearing + * on that. + */ + return status; + } + + @Override + public PaginationStatus visitOR(ExpressionParser.ORContext ctx) { + PaginationStatus lhs = visit(ctx.left); + PaginationStatus rhs = visit(ctx.right); + + if (lhs == PaginationStatus.USER_CHECK_TRUE || rhs == PaginationStatus.USER_CHECK_TRUE) { + return PaginationStatus.USER_CHECK_TRUE; + } + + if (rhs == PaginationStatus.CANNOT_PAGINATE || lhs == PaginationStatus.CANNOT_PAGINATE) { + return PaginationStatus.CANNOT_PAGINATE; + } + + return PaginationStatus.CAN_PAGINATE; + } + + @Override + public PaginationStatus visitAND(ExpressionParser.ANDContext ctx) { + PaginationStatus lhs = visit(ctx.left); + PaginationStatus rhs = visit(ctx.right); + + if (lhs == PaginationStatus.USER_CHECK_FALSE || rhs == PaginationStatus.USER_CHECK_FALSE) { + return PaginationStatus.USER_CHECK_FALSE; + } + + if (rhs == PaginationStatus.CANNOT_PAGINATE || lhs == PaginationStatus.CANNOT_PAGINATE) { + return PaginationStatus.CANNOT_PAGINATE; + } + + return PaginationStatus.CAN_PAGINATE; + } + + @Override + public PaginationStatus visitPAREN(ExpressionParser.PARENContext ctx) { + return visit(ctx.expression()); + } + + @Override + public PaginationStatus visitPermissionClass(ExpressionParser.PermissionClassContext ctx) { + Check check = dictionary.getCheckInstance(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 (((UserCheck) check).ok(scope.getUser())) { + return PaginationStatus.USER_CHECK_TRUE; + } + return PaginationStatus.USER_CHECK_FALSE; + } + //Any in memory check will alter (incorrectly) the paginated result + return PaginationStatus.CANNOT_PAGINATE; + } + + /** + * Determines whether a data store can correctly paginate a collection of resources of a given + * class for a requested set of fields. + * @param resourceClass The class of resources that will be paginated + * @param dictionary Used to look up permissions + * @param scope Contains the user object. + * @param requestedFields Contains the request info including any sparse fields that were requested + * @return true if the data store can paginate. false otherwise. + */ + public static boolean canPaginate( + Type resourceClass, + EntityDictionary dictionary, + RequestScope scope, + Set requestedFields + ) { + CanPaginateVisitor visitor = new CanPaginateVisitor(dictionary, scope, requestedFields); + + Class annotationClass = ReadPermission.class; + ParseTree classPermissions = dictionary.getPermissionsForClass(resourceClass, annotationClass); + PaginationStatus canPaginateClass = PaginationStatus.CAN_PAGINATE; + + if (classPermissions != null) { + canPaginateClass = visitor.visit(classPermissions); + } + + List fields = dictionary.getAllExposedFields(resourceClass); + + boolean canPaginate = true; + for (String field : fields) { + if (! requestedFields.isEmpty() && ! requestedFields.contains(field)) { + continue; + } + + PaginationStatus canPaginateField = canPaginateClass; + ParseTree fieldPermissions = dictionary.getPermissionsForField(resourceClass, field, annotationClass); + if (fieldPermissions != null) { + canPaginateField = visitor.visit(fieldPermissions); + } + + /* + * If any of the fields can always be seen by the user, the user can see the entire + * collection of entities (absent any fields which they cannot see). + */ + if (canPaginateField == PaginationStatus.USER_CHECK_TRUE) { + return true; + } + + /* + * Except for true user checks above, any field which cannot be paginated means the entire + * collection cannot be paginated. If one field has a filter expression check and the other has + * an in memory check, both checks must be evaluated in memory (effectively any in memory check makes + * all other checks in memory). + */ + if (canPaginateField == PaginationStatus.CANNOT_PAGINATE) { + canPaginate = false; + } + } + return canPaginate; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionNormalizationVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionNormalizationVisitor.java new file mode 100644 index 0000000000..06529a7fc1 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionNormalizationVisitor.java @@ -0,0 +1,65 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.security.visitors; + +import com.yahoo.elide.core.security.permissions.expressions.AndExpression; +import com.yahoo.elide.core.security.permissions.expressions.CheckExpression; +import com.yahoo.elide.core.security.permissions.expressions.Expression; +import com.yahoo.elide.core.security.permissions.expressions.ExpressionVisitor; +import com.yahoo.elide.core.security.permissions.expressions.NotExpression; +import com.yahoo.elide.core.security.permissions.expressions.OrExpression; + +/** + * Expression Visitor to normalize Permission expression. + */ +public class PermissionExpressionNormalizationVisitor implements ExpressionVisitor { + @Override + public Expression visitExpression(Expression expression) { + return expression; + } + + @Override + public Expression visitCheckExpression(CheckExpression checkExpression) { + return checkExpression; + } + + @Override + public Expression visitAndExpression(AndExpression andExpression) { + Expression left = andExpression.getLeft(); + Expression right = andExpression.getRight(); + return new AndExpression(left.accept(this), right.accept(this)); + } + + @Override + public Expression visitOrExpression(OrExpression orExpression) { + Expression left = orExpression.getLeft(); + Expression right = orExpression.getRight(); + return new OrExpression(left.accept(this), right.accept(this)); + } + + @Override + public Expression visitNotExpression(NotExpression notExpression) { + Expression inner = notExpression.getLogical(); + if (inner instanceof AndExpression) { + AndExpression and = (AndExpression) inner; + Expression left = new NotExpression(and.getLeft()).accept(this); + Expression right = new NotExpression(and.getRight()).accept(this); + return new OrExpression(left, right); + } + if (inner instanceof OrExpression) { + OrExpression or = (OrExpression) inner; + Expression left = new NotExpression(or.getLeft()).accept(this); + Expression right = new NotExpression(or.getRight()).accept(this); + return new AndExpression(left, right); + } + if (inner instanceof NotExpression) { + NotExpression not = (NotExpression) inner; + return (not.getLogical()).accept(this); + } + return notExpression; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionVisitor.java new file mode 100644 index 0000000000..5c28fb87a6 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionVisitor.java @@ -0,0 +1,63 @@ +/* + * 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.security.visitors; + +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.security.permissions.expressions.AndExpression; +import com.yahoo.elide.core.security.permissions.expressions.Expression; +import com.yahoo.elide.core.security.permissions.expressions.NotExpression; +import com.yahoo.elide.core.security.permissions.expressions.OrExpression; +import com.yahoo.elide.generated.parsers.ExpressionBaseVisitor; +import com.yahoo.elide.generated.parsers.ExpressionParser; + +import java.util.function.Function; + +/** + * Expression Visitor. + */ +public class PermissionExpressionVisitor extends ExpressionBaseVisitor { + private final EntityDictionary dictionary; + private final Function expressionGenerator; + + + public PermissionExpressionVisitor(EntityDictionary dictionary, Function expressionGenerator) { + this.dictionary = dictionary; + this.expressionGenerator = expressionGenerator; + } + + + @Override + public Expression visitNOT(ExpressionParser.NOTContext ctx) { + // Create a not expression + return new NotExpression(visit(ctx.expression())); + } + + @Override + public Expression visitOR(ExpressionParser.ORContext ctx) { + return new OrExpression(visit(ctx.left), visit(ctx.right)); + } + + @Override + public Expression visitAND(ExpressionParser.ANDContext ctx) { + Expression left = visit(ctx.left); + Expression right = visit(ctx.right); + return new AndExpression(left, right); + } + + @Override + public Expression visitPAREN(ExpressionParser.PARENContext ctx) { + return visit(ctx.expression()); + } + + @Override + public Expression visitPermissionClass(ExpressionParser.PermissionClassContext ctx) { + Check check = dictionary.getCheckInstance(ctx.getText()); + + return expressionGenerator.apply(check); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionToFilterExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionToFilterExpressionVisitor.java new file mode 100644 index 0000000000..503a74eb46 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionToFilterExpressionVisitor.java @@ -0,0 +1,205 @@ +/* + * 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.security.visitors; + +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.Operator; +import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpression; +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.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.security.checks.FilterExpressionCheck; +import com.yahoo.elide.core.security.checks.UserCheck; +import com.yahoo.elide.core.security.permissions.expressions.AndExpression; +import com.yahoo.elide.core.security.permissions.expressions.CheckExpression; +import com.yahoo.elide.core.security.permissions.expressions.Expression; +import com.yahoo.elide.core.security.permissions.expressions.ExpressionVisitor; +import com.yahoo.elide.core.security.permissions.expressions.NotExpression; +import com.yahoo.elide.core.security.permissions.expressions.OrExpression; +import com.yahoo.elide.core.type.Type; + +import java.util.Objects; + + +/** + * PermissionToFilterExpressionVisitor parses a permission parseTree and returns the corresponding FilterExpression + * representation of it. This allows passing a security permission predicate down to datastore level to reduce + * in-memory permission verification workload. + * A few cases is not allow and will throw exception: + * 1. User define FilterExpressionCheck which returns null in getFilterExpression function. + * 2. User put a FilterExpressionCheck with a non-userCheck type check in OR relation. + */ +public class PermissionToFilterExpressionVisitor implements ExpressionVisitor { + private final EntityDictionary dictionary; + private final Type entityClass; + private final RequestScope requestScope; + + /** + * This is a constant that represents a check that we cannot evaluate at extraction time. + * Usually this is an inline or commit-time check. + */ + public static final FilterExpression NO_EVALUATION_EXPRESSION = new FilterExpression() { + @Override + public T accept(FilterExpressionVisitor visitor) { + return (T) this; + } + @Override + public String toString() { + return "NO_EVALUATION_EXPRESSION"; + } + }; + + /** + * This represents a user check that has evaluated to false. + */ + public static final FilterExpression FALSE_USER_CHECK_EXPRESSION = new FilterExpression() { + @Override + public T accept(FilterExpressionVisitor visitor) { + return (T) this; + } + @Override + public String toString() { + return "FALSE_USER_CHECK_EXPRESSION"; + } + }; + + /** + * This represents a user check that has evaluated to true. + */ + public static final FilterExpression TRUE_USER_CHECK_EXPRESSION = new FilterExpression() { + @Override + public T accept(FilterExpressionVisitor visitor) { + return (T) this; + } + @Override + public String toString() { + return "TRUE_USER_EXPRESSION"; + } + }; + + public PermissionToFilterExpressionVisitor(EntityDictionary dictionary, RequestScope requestScope, + Type entityClass) { + this.dictionary = dictionary; + this.requestScope = requestScope; + this.entityClass = entityClass; + } + + @Override + public FilterExpression visitNotExpression(NotExpression notExpression) { + FilterExpression expression = notExpression.getLogical().accept(this); + if (Objects.equals(expression, TRUE_USER_CHECK_EXPRESSION)) { + return FALSE_USER_CHECK_EXPRESSION; + } + if (Objects.equals(expression, FALSE_USER_CHECK_EXPRESSION)) { + return TRUE_USER_CHECK_EXPRESSION; + } + if (Objects.equals(expression, NO_EVALUATION_EXPRESSION)) { + return NO_EVALUATION_EXPRESSION; + } + if (expression instanceof FilterPredicate) { + return ((FilterPredicate) expression).negate(); + } + return new NotFilterExpression(expression); + } + + @Override + public FilterExpression visitOrExpression(OrExpression orExpression) { + FilterExpression left = orExpression.getLeft().accept(this); + FilterExpression right = orExpression.getRight().accept(this); + + if (expressionWillNotFilter(left)) { + return left; + } + + if (expressionWillNotFilter(right)) { + return right; + } + + boolean leftFails = expressionWillFail(left); + boolean rightFails = expressionWillFail(right); + if (leftFails && rightFails) { + return FALSE_USER_CHECK_EXPRESSION; + } + if (leftFails) { + return right; + } + if (rightFails) { + return left; + } + + return new OrFilterExpression(left, right); + } + + @Override + public FilterExpression visitAndExpression(AndExpression andExpression) { + FilterExpression left = andExpression.getLeft().accept(this); + FilterExpression right = andExpression.getRight().accept(this); + + // (FALSE_USER_CHECK_EXPRESSION AND FilterExpression) => FALSE_USER_CHECK_EXPRESSION + // (FALSE_USER_CHECK_EXPRESSION AND NO_EVALUATION_EXPRESSION) => FALSE_USER_CHECK_EXPRESSION + if (expressionWillFail(left) || expressionWillFail(right)) { + return FALSE_USER_CHECK_EXPRESSION; + } + + if (expressionWillNotFilter(left)) { + return right; + } + + if (expressionWillNotFilter(right)) { + return left; + } + + return new AndFilterExpression(left, right); + } + + private boolean expressionWillFail(FilterExpression expression) { + return Objects.equals(expression, FALSE_USER_CHECK_EXPRESSION) || operator(expression) == Operator.FALSE; + } + + private boolean expressionWillNotFilter(FilterExpression expression) { + return Objects.equals(expression, NO_EVALUATION_EXPRESSION) + || Objects.equals(expression, TRUE_USER_CHECK_EXPRESSION) + || operator(expression) == Operator.TRUE; + } + + @Override + public FilterExpression visitCheckExpression(CheckExpression checkExpression) { + Check check = checkExpression.getCheck(); + if (check instanceof FilterExpressionCheck) { + FilterExpressionCheck filterCheck = (FilterExpressionCheck) check; + FilterExpression filterExpression = filterCheck.getFilterExpression(entityClass, requestScope); + + if (filterExpression == null) { + throw new IllegalStateException("FilterCheck#getFilterExpression must not return null."); + } + + return filterExpression; + } + + if (check instanceof UserCheck) { + boolean userCheckResult = ((UserCheck) check).ok(requestScope.getUser()); + return userCheckResult ? TRUE_USER_CHECK_EXPRESSION : FALSE_USER_CHECK_EXPRESSION; + } + + return NO_EVALUATION_EXPRESSION; + } + + private Operator operator(FilterExpression expression) { + return expression instanceof FilterPredicate + ? ((FilterPredicate) expression).getOperator() + : null; + } + + @Override + public FilterExpression visitExpression(Expression expression) { + return NO_EVALUATION_EXPRESSION; + } +} 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/Sorting.java deleted file mode 100644 index ff2479183e..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/core/sort/Sorting.java +++ /dev/null @@ -1,140 +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.sort; - -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.exceptions.InvalidValueException; -import lombok.ToString; - -import javax.ws.rs.core.MultivaluedMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * 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 } - - private final Map sortRules = new LinkedHashMap<>(); - private static final Sorting DEFAULT_EMPTY_INSTANCE = new Sorting(null); - - /** - * Constructs a new Sorting instance. - * @param sortingRules The map of sorting rules - */ - public Sorting(final Map sortingRules) { - if (sortingRules != null) { - sortRules.putAll(sortingRules); - } - } - - /** - * Checks to see if the sorting rules are valid for the given JPA class. - * @param entityClass The target jpa entity - * @param dictionary The elide entity dictionary - * @param The Type of the target entity - * @return The validity of the sorting rules on the target class - * @throws InvalidValueException when sorting values are not valid for the jpa entity - */ - public boolean hasValidSortingRules(final Class entityClass, - final EntityDictionary dictionary) throws InvalidValueException { - - final List entities = dictionary.getAttributes(entityClass); - sortRules.keySet().stream().forEachOrdered(sortRule -> { - if (!entities.contains(sortRule)) { - throw new InvalidValueException(entityClass.getSimpleName() - + " doesn't contain the field " + sortRule); - } - }); - return true; - } - - /** - * Given the sorting rules validate sorting rules against the entities bound to the entityClass. - * @param entityClass The root class for sorting (eg. /book?sort=-title this would be package.Book) - * @param dictionary The elide entity dictionary - * @param The entityClass - * @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) - throws InvalidValueException { - hasValidSortingRules(entityClass, dictionary); - return sortRules; - } - - /** - * @return Fetches the base rules, ignoring validation against an entity class. - */ - public Map getSortingRules() { - return this.sortRules; - } - - /** - * Informs if the structure is default instance. - * @return true if this instance is empty - no sorting rules - */ - public boolean isDefaultInstance() { - return this.sortRules.isEmpty(); - } - - /** - * Given the query params on the GET request, collect possible sorting rules. - * @param queryParams The query params on the request. - * @return The Sorting instance (default or specific). - */ - public static Sorting parseQueryParams(final MultivaluedMap queryParams) { - final Map sortingRules = new LinkedHashMap<>(); - queryParams.entrySet().stream() - .filter(entry -> entry.getKey().equals("sort")) - .forEachOrdered(entry -> { - String sortRule = entry.getValue().get(0); - if (sortRule.contains(",")) { - for (String sortRuleSplit : sortRule.split(",")) { - parseSortRule(sortRuleSplit, sortingRules); - } - } else { - parseSortRule(sortRule, sortingRules); - } - }); - return sortingRules.isEmpty() ? DEFAULT_EMPTY_INSTANCE : new Sorting(sortingRules); - } - - /** - * Internal helper method to parse sorting rule strings. - * @param sortRule The string from the queryParams - * @param sortingRules The final shared reference to the sortingRules map - */ - private static void parseSortRule(String sortRule, final Map sortingRules) { - boolean isDesc = false; - char firstCharacter = sortRule.charAt(0); - if (firstCharacter == '-') { - isDesc = true; - sortRule = sortRule.substring(1); - } - if (firstCharacter == '+') { - // json-api spec supports asc by default, there is no need to explicitly support + - sortRule = sortRule.substring(1); - } - sortingRules.put(sortRule, isDesc ? SortOrder.desc : SortOrder.asc); - } - - /** - * Get the default final empty instance. - * @return The default empty instance. - */ - public static Sorting getDefaultEmptyInstance() { - return DEFAULT_EMPTY_INSTANCE; - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java b/elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java new file mode 100644 index 0000000000..63927c8730 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java @@ -0,0 +1,275 @@ +/* + * 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.sort; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.InvalidValueException; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.Sorting; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Generates a simple wrapper around the sort fields from the JSON-API GET Query. + */ +@ToString +@EqualsAndHashCode +public class SortingImpl implements Sorting { + + private final Map sortRules = new LinkedHashMap<>(); + private static final SortingImpl DEFAULT_EMPTY_INSTANCE = null; + private static final String JSONAPI_ID_KEYWORD = "id"; + + @Getter + private Type type; + + @Getter + private Map sortingPaths; + + private Set attributes; + + /** + * Constructs a new Sorting instance. + * @param sortingRules The map of sorting rules + * @param type The model being sorted + * @param dictionary The entity dictionary + */ + public SortingImpl(final Map sortingRules, Type type, EntityDictionary dictionary) { + this(sortingRules, type, Collections.emptySet(), dictionary); + } + + /** + * Constructs a new Sorting instance. + * @param sortingRules The map of sorting rules + * @param type The model being sorted + * @param dictionary The entity dictionary + */ + public SortingImpl(final Map sortingRules, Class type, EntityDictionary dictionary) { + this(sortingRules, ClassType.of(type), dictionary); + } + + /** + * Constructs a new Sorting instance. + * @param sortingRules The map of sorting rules + * @param type The model being sorted + * @param attributes The set of attributes being requested + * @param dictionary The entity dictionary + */ + public SortingImpl(final Map sortingRules, Type type, + Set attributes, EntityDictionary dictionary) { + if (sortingRules != null) { + sortRules.putAll(sortingRules); + } + + this.attributes = attributes; + this.type = type; + sortingPaths = getValidSortingRules(type, attributes, dictionary); + } + + /** + * Given the sorting rules validate sorting rules against the entities bound to the entityClass. + * @param entityClass The root class for sorting (eg. /book?sort=-title this would be package.Book) + * @param attributes The attributes that are being requested for the sorted model + * @param dictionary The elide entity dictionary + * @param The entityClass + * @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 + */ + private Map getValidSortingRules(final Type entityClass, + final Set attributes, + final EntityDictionary dictionary) + throws InvalidValueException { + Map returnMap = new LinkedHashMap<>(); + for (Map.Entry entry : replaceIdRule(dictionary.getIdFieldName(entityClass)).entrySet()) { + String dotSeparatedPath = entry.getKey(); + SortOrder order = entry.getValue(); + + Path path; + if (dotSeparatedPath.contains(".")) { + //Creating a path validates that the dot separated path is valid. + path = new Path(entityClass, dictionary, dotSeparatedPath); + } else { + Attribute attribute = attributes.stream().filter(attr -> attr.getName().equals(dotSeparatedPath) + || attr.getAlias().equals(dotSeparatedPath)) + .findFirst().orElse(null); + + if (attribute == null) { + path = new Path(entityClass, dictionary, dotSeparatedPath); + } else { + path = new Path(entityClass, dictionary, attribute.getName(), + attribute.getAlias(), attribute.getArguments()); + } + } + + if (! isValidSortRulePath(path, dictionary)) { + throw new InvalidValueException("Cannot sort across a to-many relationship: " + path.getFieldPath()); + } + + returnMap.put(path, order); + } + + return returnMap; + } + + /** + * Validates that none of the provided path's relationships are to-many. + * @param path The path to validate + * @param dictionary The elide entity dictionary + * @return True if the path is valid. False otherwise. + */ + protected static boolean isValidSortRulePath(Path path, EntityDictionary dictionary) { + //Validate that none of the relationships are to-many + for (Path.PathElement pathElement : path.getPathElements()) { + if (! dictionary.isRelation(pathElement.getType(), pathElement.getFieldName())) { + continue; + } + + if (dictionary.getRelationshipType(pathElement.getType(), pathElement.getFieldName()).isToMany()) { + return false; + } + } + return true; + } + + /** + * 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(); + } + + /** + * Given the query params on the GET request, collect possible sorting rules. + * @param queryParams The query params on the request. + * @return The Sorting instance (default or specific). + */ + public static Sorting parseQueryParams(final MultivaluedMap queryParams, + Type type, EntityDictionary dictionary) { + + if (queryParams.isEmpty()) { + return DEFAULT_EMPTY_INSTANCE; + } + + List sortRules = queryParams.entrySet().stream() + .filter(entry -> entry.getKey().equals("sort")) + .map(entry -> entry.getValue().get(0)) + .collect(Collectors.toList()); + return parseSortRules(sortRules, type, Collections.emptySet(), dictionary); + } + + /** + * Parse a raw sort rule. + * @param sortRule Sorting string to parse + * @param type The model to sort + * @param dictionary The entity dictionary + * @return Sorting object. + */ + public static Sorting parseSortRule(String sortRule, Type type, EntityDictionary dictionary) { + return parseSortRules(Arrays.asList(sortRule), type, Collections.emptySet(), dictionary); + } + + /** + * Parse a raw sort rule. + * @param sortRule Sorting string to parse + * @param type The model to sort + * @param attributes The requested attributes of the model + * @param dictionary The entity dictionary + * @return Sorting object. + */ + public static Sorting parseSortRule(String sortRule, Type type, + Set attributes, EntityDictionary dictionary) { + return parseSortRules(Arrays.asList(sortRule), type, attributes, dictionary); + } + + /** + * Internal helper to parse list of sorting rules. + * @param sortRules Sorting rules to parse + * @param type The model to sort + * @param attributes The requested attributes of the model + * @param dictionary The entity dictionary + * @return Sorting object containing parsed sort rules + */ + private static SortingImpl parseSortRules(List sortRules, + Type type, + Set attributes, + EntityDictionary dictionary) { + final Map sortingRules = new LinkedHashMap<>(); + for (String sortRule : sortRules) { + if (sortRule.contains(",")) { + for (String sortRuleSplit : sortRule.split(",")) { + parseSortRule(sortRuleSplit, sortingRules); + } + } else { + parseSortRule(sortRule, sortingRules); + } + } + return sortingRules.isEmpty() + ? DEFAULT_EMPTY_INSTANCE + : new SortingImpl(sortingRules, type, attributes, dictionary); + } + + /** + * Internal helper method to parse sorting rule strings. + * @param sortRule The string from the queryParams + * @param sortingRules The final shared reference to the sortingRules map + */ + private static void parseSortRule(String sortRule, final Map sortingRules) { + boolean isDesc = false; + char firstCharacter = sortRule.charAt(0); + if (firstCharacter == '-') { + isDesc = true; + sortRule = sortRule.substring(1); + } + if (firstCharacter == '+') { + // json-api spec supports asc by default, there is no need to explicitly support + + sortRule = sortRule.substring(1); + } + sortingRules.put(sortRule, isDesc ? SortOrder.desc : SortOrder.asc); + } + + /** + * Replace id with proper field for object. + * + * @param idFieldName Name of the object's id field. + * @return Sort rules with id field name replaced + */ + private LinkedHashMap replaceIdRule(String idFieldName) { + LinkedHashMap result = new LinkedHashMap<>(); + for (Map.Entry entry : sortRules.entrySet()) { + String key = entry.getKey(); + SortOrder value = entry.getValue(); + if (JSONAPI_ID_KEYWORD.equals(key)) { + result.put(idFieldName, value); + } else { + result.put(key, value); + } + } + return result; + } + + /** + * Get the default final empty instance. + * @return The default empty instance. + */ + public static Sorting getDefaultEmptyInstance() { + return DEFAULT_EMPTY_INSTANCE; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/AccessibleObject.java b/elide-core/src/main/java/com/yahoo/elide/core/type/AccessibleObject.java new file mode 100644 index 0000000000..bbf951ffba --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/AccessibleObject.java @@ -0,0 +1,71 @@ +/* + * 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.type; + +import java.lang.annotation.Annotation; + +/** + * Base interface for fields and methods. + */ +public interface AccessibleObject extends Member { + + /** + * The name of the field or method. + * @return the name. + */ + String getName(); + + /** + * Changes whether the field or method is accessible. + * @param flag true or false. + */ + default void setAccessible(boolean flag) { + //NOOP + } + + /** + * Determines whether the member is synthetic. + * @return True or false. + */ + default boolean isSynthetic() { + return false; + } + + /** + * @param annotationClass The annotation to search for. + * @return True or false. + */ + boolean isAnnotationPresent(Class annotationClass); + + /** + * Searches for a specific annotation. + * @param annotationClass The annotation to search for. + * @param The annotation type to search for. + * @return The annotation or null. + */ + T getAnnotation(Class annotationClass); + + /** + * Searches for a specific annotation. + * @param annotationClass The annotation to search for. + * @param The annotation type to search for. + * @return A list of annotations or null. + */ + T[] getAnnotationsByType(Class annotationClass); + + /** + * Returns all annotations ignoring inherited ones. + * @return A list of annotations or null. + */ + Annotation[] getDeclaredAnnotations(); + + /** + * Returns all annotations. + * @return A list of annotations or null. + */ + Annotation[] getAnnotations(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/ClassType.java b/elide-core/src/main/java/com/yahoo/elide/core/type/ClassType.java new file mode 100644 index 0000000000..ac02fc28d6 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/ClassType.java @@ -0,0 +1,264 @@ +/* + * 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.type; + +import com.google.common.collect.ImmutableList; +import lombok.Getter; + +import java.lang.annotation.Annotation; +import java.lang.reflect.ParameterizedType; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * An elide type that wraps a Java class. + * @param The Java class. + */ +public class ClassType implements Type { + public static final List OBJ_METHODS = ImmutableList.copyOf( + Arrays.stream(Object.class.getMethods()).map(ClassType::constructMethod).collect(Collectors.toList())); + + public static final ClassType MAP_TYPE = ClassType.of(Map.class); + public static final ClassType COLLECTION_TYPE = ClassType.of(Collection.class); + public static final ClassType STRING_TYPE = ClassType.of(String.class); + public static final ClassType BOOLEAN_TYPE = ClassType.of(Boolean.class); + public static final ClassType LONG_TYPE = ClassType.of(Long.class); + public static final ClassType BIGDECIMAL_TYPE = ClassType.of(BigDecimal.class); + public static final ClassType NUMBER_TYPE = ClassType.of(Number.class); + public static final ClassType DATE_TYPE = ClassType.of(Date.class); + public static final ClassType OBJECT_TYPE = ClassType.of(Object.class); + public static final ClassType CLASS_TYPE = ClassType.of(Class.class); + public static final ClassType INTEGER_TYPE = ClassType.of(Integer.class); + + @Getter + private Class cls; + + /** + * Constructor. + * @param cls The underlying java class. + */ + public ClassType(Class cls) { + this.cls = cls; + } + + @Override + public String getCanonicalName() { + return cls.getCanonicalName(); + } + + @Override + public String getSimpleName() { + return cls.getSimpleName(); + } + + @Override + public String getName() { + return cls.getName(); + } + + @Override + public Type getSuperclass() { + Class superClass = cls.getSuperclass(); + if (superClass == null) { + return null; + } + + return new ClassType(superClass); + } + + @Override + public A[] getAnnotationsByType(Class annotationClass) { + return cls.getAnnotationsByType(annotationClass); + } + + @Override + public Field[] getFields() { + return Arrays.stream(cls.getFields()) + .map(ClassType::constructField).toArray(Field[]::new); + } + + @Override + public Field[] getDeclaredFields() { + return Arrays.stream(cls.getDeclaredFields()) + .map(ClassType::constructField).toArray(Field[]::new); + } + + @Override + public Field getDeclaredField(String name) throws NoSuchFieldException { + return constructField(cls.getDeclaredField(name)); + } + + @Override + public Method[] getConstructors() { + return Arrays.stream(cls.getConstructors()) + .map(ClassType::constructMethod).toArray(Method[]::new); + } + + @Override + public boolean isParameterized() { + return (cls.getGenericSuperclass() instanceof ParameterizedType); + } + + @Override + public boolean hasSuperType() { + return cls != null && cls != Object.class; + } + + @Override + public T newInstance() throws InstantiationException, IllegalAccessException { + return cls.newInstance(); + } + + @Override + public Package getPackage() { + return constructPackage(cls.getPackage()); + } + + @Override + public Method[] getMethods() { + return Arrays.stream(cls.getMethods()) + .map(ClassType::constructMethod).toArray(Method[]::new); + } + + @Override + public Method[] getDeclaredMethods() { + return Arrays.stream(cls.getDeclaredMethods()) + .map(ClassType::constructMethod).toArray(Method[]::new); + } + + @Override + public boolean isAssignableFrom(Type other) { + Optional> clazz = other.getUnderlyingClass(); + + return clazz.filter(cls::isAssignableFrom).isPresent(); + } + + @Override + public boolean isPrimitive() { + return cls.isPrimitive(); + } + + @Override + public A getAnnotation(Class annotationClass) { + return cls.getAnnotation(annotationClass); + } + + @Override + public A getDeclaredAnnotation(Class annotationClass) { + return cls.getDeclaredAnnotation(annotationClass); + } + + @Override + public boolean isAnnotationPresent(Class annotationClass) { + return getAnnotation(annotationClass) != null; + } + + @Override + public Method getMethod(String name, Type... parameterTypes) throws NoSuchMethodException { + Class[] typeParams = Arrays.stream(parameterTypes) + .map(ClassType.class::cast) + .map(classType -> (classType == null) ? null : classType.getCls()) + .toArray(Class[]::new); + + return constructMethod(cls.getMethod(name, typeParams)); + } + + private static Field constructField(java.lang.reflect.Field field) { + if (field == null) { + return null; + } + + return new FieldType(field); + } + + private static Method constructMethod(java.lang.reflect.Executable method) { + if (method == null) { + return null; + } + + return new MethodType(method); + } + + private static Package constructPackage(java.lang.Package pkg) { + if (pkg == null) { + return null; + } + + return new Package() { + @Override + public A getDeclaredAnnotation(Class annotationClass) { + return pkg.getDeclaredAnnotation(annotationClass); + } + + @Override + public String getName() { + return pkg.getName(); + } + + @Override + public Package getParentPackage() { + String name = pkg.getName(); + int idx = name.lastIndexOf('.'); + return idx == -1 ? null : constructPackage(java.lang.Package.getPackage(name.substring(0, idx))); + } + }; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ClassType classType = (ClassType) o; + return Objects.equals(cls, classType.cls); + } + + @Override + public int hashCode() { + return Objects.hashCode(cls); + } + + @Override + public String toString() { + return "ClassType{" + "cls=" + cls + '}'; + } + + @Override + public boolean isEnum() { + return cls.isEnum(); + } + + @Override + public T[] getEnumConstants() { + return cls.getEnumConstants(); + } + + @Override + public Optional> getUnderlyingClass() { + return Optional.of(getCls()); + } + + /** + * Construction helper. + * @param class type + * @param cls The underlying java class. + * @return wrapped Type + */ + public static ClassType of(Class cls) { + return cls == null ? null : new ClassType<>(cls); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Dynamic.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Dynamic.java new file mode 100644 index 0000000000..0c0461e6cb --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Dynamic.java @@ -0,0 +1,20 @@ +/* + * 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.type; + +/** + * All objects created or loaded by a DataStore that are not associated with a ClassType + * must inherit from this interface. + */ +public interface Dynamic { + + /** + * Get the underlying Elide type associated with this object. + * @return The Elide type. + */ + Type getType(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Field.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Field.java new file mode 100644 index 0000000000..ac191e6bb2 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Field.java @@ -0,0 +1,47 @@ +/* + * 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.type; + +import java.util.Optional; + +/** + * A field in an Elide type. + */ +public interface Field extends AccessibleObject { + + /** + * Get the value of the field. + * @param obj The object to access. + * @return The field value. + * @throws IllegalArgumentException + * @throws IllegalAccessException if the field cannot be accessed. + */ + Object get(Object obj) throws IllegalArgumentException, IllegalAccessException; + + /** + * Get the Elide type associated with the field. + * @return + */ + Type getType(); + + /** + * Returns the Elide type of a specific parameter of a parameterized type. + * @param parentType The parameterized type. + * @param index Which parameter should be retrieved. + * @return The parameter type. + */ + Type getParameterizedType(Type parentType, Optional index); + + /** + * Changes the value of the field. + * @param obj The object containing the field. + * @param value The new value. + * @throws IllegalArgumentException + * @throws IllegalAccessException If the field cannot be accessed. + */ + void set(Object obj, Object value) throws IllegalArgumentException, IllegalAccessException; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/FieldType.java b/elide-core/src/main/java/com/yahoo/elide/core/type/FieldType.java new file mode 100644 index 0000000000..b758b55b2e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/FieldType.java @@ -0,0 +1,103 @@ +/* + * 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.type; + +import org.apache.commons.lang3.reflect.TypeUtils; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; + +import java.lang.annotation.Annotation; +import java.lang.reflect.ParameterizedType; +import java.util.Optional; + +/** + * Elide field that wraps a Java field. + */ +@AllArgsConstructor +@EqualsAndHashCode +public class FieldType implements Field { + + private java.lang.reflect.Field field; + + @Override + public void setAccessible(boolean flag) { + field.setAccessible(flag); + } + + @Override + public boolean isSynthetic() { + return field.isSynthetic(); + } + + @Override + public boolean isAnnotationPresent(Class annotationClass) { + return field.isAnnotationPresent(annotationClass); + } + + @Override + public T getAnnotation(Class annotationClass) { + return field.getAnnotation(annotationClass); + } + + @Override + public T[] getAnnotationsByType(Class annotationClass) { + return field.getAnnotationsByType(annotationClass); + } + + @Override + public Annotation[] getDeclaredAnnotations() { + return field.getDeclaredAnnotations(); + } + + @Override + public Annotation[] getAnnotations() { + return field.getAnnotations(); + } + + @Override + public int getModifiers() { + return field.getModifiers(); + } + + @Override + public String getName() { + return field.getName(); + } + + @Override + public Object get(Object obj) throws IllegalArgumentException, IllegalAccessException { + return field.get(obj); + } + + @Override + public Type getType() { + return ClassType.of(field.getType()); + } + + @Override + public Type getParameterizedType(Type parentType, Optional index) { + if (parentType instanceof Dynamic) { + return getType(); + } + + java.lang.reflect.Type type = field.getGenericType(); + + if (type instanceof ParameterizedType && index.isPresent()) { + return ClassType.of( + TypeUtils.getRawType( + ((ParameterizedType) type).getActualTypeArguments()[index.get().intValue()], + ((ClassType) parentType).getCls() + ) + ); + } + return ClassType.of(TypeUtils.getRawType(type, ((ClassType) parentType).getCls())); + } + + @Override + public void set(Object obj, Object value) throws IllegalArgumentException, IllegalAccessException { + field.set(obj, value); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Member.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Member.java new file mode 100644 index 0000000000..e1afbeab41 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Member.java @@ -0,0 +1,19 @@ +/* + * 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.type; + +/** + * Base class of fields and methods. + */ +public interface Member { + + /** + * Get the permission modifiers of the field/method. + * @return The permission modifiers. + */ + int getModifiers(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Method.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Method.java new file mode 100644 index 0000000000..e2ef9fafb0 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Method.java @@ -0,0 +1,54 @@ +/* + * 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.type; + +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; + +/** + * A method belonging to an Elide type. + */ +public interface Method extends AccessibleObject { + + /** + * Get the number of parameters the method takes. + * @return The parameter count. + */ + int getParameterCount(); + + /** + * Invokes the method. + * @param obj The object containing the method. + * @param args The parameters + * @return The return value of the method. + * @throws IllegalAccessException If the method cannot be accessed. + * @throws IllegalArgumentException + * @throws InvocationTargetException If the object doesn't contain this method. + */ + Object invoke(Object obj, Object... args) throws IllegalAccessException, + IllegalArgumentException, InvocationTargetException; + + /** + * Gets the return type of the method. + * @return The return type. + */ + Type getReturnType(); + + /** + * Gets a parameter type if the return type is parameterized. + * @param parentType The return type. + * @param index Which parameter type to return. + * @return The parameter type. + */ + Type getParameterizedReturnType(Type parentType, Optional index); + + /** + * Returns all the parameter types. + * @return All the parameter types. + */ + Class[] getParameterTypes(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/MethodType.java b/elide-core/src/main/java/com/yahoo/elide/core/type/MethodType.java new file mode 100644 index 0000000000..4eb8873521 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/MethodType.java @@ -0,0 +1,120 @@ +/* + * 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.type; + +import org.apache.commons.lang3.reflect.TypeUtils; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; + +import java.lang.annotation.Annotation; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.util.Optional; + +/** + * Elide Method that wraps a Java Method. + */ +@AllArgsConstructor +@EqualsAndHashCode +public class MethodType implements Method { + + private java.lang.reflect.Executable method; + + @Override + public int getModifiers() { + return method.getModifiers(); + } + + @Override + public void setAccessible(boolean flag) { + method.setAccessible(flag); + } + + @Override + public boolean isSynthetic() { + return method.isSynthetic(); + } + + @Override + public boolean isAnnotationPresent(Class annotation) { + return method.isAnnotationPresent(annotation); + } + + @Override + public T getAnnotation(Class annotationClass) { + return method.getAnnotation(annotationClass); + } + + @Override + public T[] getAnnotationsByType(Class annotationClass) { + return method.getAnnotationsByType(annotationClass); + } + + @Override + public Annotation[] getDeclaredAnnotations() { + return method.getDeclaredAnnotations(); + } + + @Override + public Annotation[] getAnnotations() { + return method.getAnnotations(); + } + + @Override + public String getName() { + return method.getName(); + } + + @Override + public int getParameterCount() { + return method.getParameterCount(); + } + + @Override + public Object invoke(Object obj, Object... args) throws IllegalAccessException, + IllegalArgumentException, InvocationTargetException { + if (! (method instanceof java.lang.reflect.Method)) { + throw new UnsupportedOperationException("Constructors cannot be invoked"); + } + return ((java.lang.reflect.Method) method).invoke(obj, args); + } + + @Override + public Type getReturnType() { + if (! (method instanceof java.lang.reflect.Method)) { + throw new UnsupportedOperationException("Constructors cannot be invoked"); + } + return ClassType.of(((java.lang.reflect.Method) method).getReturnType()); + } + + @Override + public Type getParameterizedReturnType(Type parentType, Optional index) { + if (! (method instanceof java.lang.reflect.Method)) { + throw new UnsupportedOperationException("Constructors cannot be invoked"); + } + + if (parentType instanceof Dynamic) { + return getReturnType(); + } + + java.lang.reflect.Type type = ((java.lang.reflect.Method) method).getGenericReturnType(); + + if (type instanceof ParameterizedType && index.isPresent()) { + return ClassType.of( + TypeUtils.getRawType( + ((ParameterizedType) type).getActualTypeArguments()[index.get().intValue()], + ((ClassType) parentType).getCls() + ) + ); + } + return ClassType.of(TypeUtils.getRawType(type, ((ClassType) parentType).getCls())); + } + + @Override + public Class[] getParameterTypes() { + return method.getParameterTypes(); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Package.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Package.java new file mode 100644 index 0000000000..b7e7db0ccf --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Package.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.core.type; + +import java.lang.annotation.Annotation; + +/** + * Elide package for one or more types. + */ +public interface Package { + + /** + * Gets the annotations of a specific type. + * @param annotationClass The annotation to search for. + * @param The annotation to search for. + * @return The annotation if found or null. + */ + A getDeclaredAnnotation(Class annotationClass); + + /** + * Returns the name of the package. + * @return the package name. + */ + String getName(); + + /** + * Returns the name of the parent package. + * @return the parent package. + */ + Package getParentPackage(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedAttribute.java b/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedAttribute.java new file mode 100644 index 0000000000..f031aa4cd7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedAttribute.java @@ -0,0 +1,26 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.type; + +import com.yahoo.elide.core.request.Argument; + +import java.util.Set; + +/** + * An elide attribute that supports parameters. + */ +@FunctionalInterface +public interface ParameterizedAttribute { + + /** + * Fetch the attribute value with the specified parameters. + * @param arguments The attribute arguments. + * @param The return type of the attribute. + * @return The attribute value. + */ + public T invoke(Set arguments); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedModel.java b/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedModel.java new file mode 100644 index 0000000000..a76d1c89fd --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedModel.java @@ -0,0 +1,84 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.type; + +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.core.exceptions.InvalidParameterizedAttributeException; +import com.yahoo.elide.core.request.Argument; +import com.yahoo.elide.core.request.Attribute; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Base class that contains one or more parameterized attributes. + */ +public abstract class ParameterizedModel { + + @Exclude + protected Map parameterizedAttributes; + + public ParameterizedModel() { + this(new HashMap<>()); + } + + public ParameterizedModel(Map attributes) { + this.parameterizedAttributes = attributes; + } + + public void addAttributeValue(Attribute attribute, T value) { + parameterizedAttributes.put(attribute, + new ParameterizedAttribute() { + @Override + public T invoke(Set arguments) { + return (T) value; + } + }); + } + + /** + * Fetch the attribute value with the specified parameters. + * @param attribute The attribute to fetch. + * @param The return type of the attribute. + * @return The attribute value. + */ + public T invoke(Attribute attribute) { + return parameterizedAttributes.entrySet().stream() + + //Only filter by alias required. (Filtering by type may not work with inheritance). + .filter(entry -> attribute.getAlias().equals(entry.getKey().getAlias())) + .findFirst() + .map(Map.Entry::getValue) + .orElseThrow(() -> new InvalidParameterizedAttributeException(attribute)) + .invoke(attribute.getArguments()); + } + + /** + * Fetch the attribute value by name. + * @param alias The field name to fetch. + * @param defaultValue Returned if the field name is not found + * @param The return type of the attribute. + * @return The attribute value or the provided default value. + */ + public T fetch(String alias, T defaultValue) { + Optional match = parameterizedAttributes.entrySet().stream() + + //Only filter by alias required. (Filtering by type may not work with inheritance). + .filter(entry -> alias.equals(entry.getKey().getAlias())) + .findFirst() + .map(Map.Entry::getValue); + + if (! match.isPresent()) { + return defaultValue; + } + + return match.get().invoke(new HashSet<>()); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Type.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Type.java new file mode 100644 index 0000000000..46f9a38a12 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Type.java @@ -0,0 +1,179 @@ +/* + * 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.type; + +import java.lang.annotation.Annotation; +import java.util.Optional; + +/** + * Elide type for models and their attributes. + * @param The underlying Java class. + */ +public interface Type extends java.lang.reflect.Type { + + /** + * Gets the canonical name of the class containing no $ symbols. + * @return The canonical name. + */ + String getCanonicalName(); + + /** + * Gets the simple name of the class without package prefix. + * @return The simple name. + */ + String getSimpleName(); + + + /** + * Get the name of the class including package prefix. + * @return The name of the class. + */ + String getName(); + + /** + * Returns a method belonging to a type. + * @param name The method name. + * @param parameterTypes The parameter types. + * @return The method. + * @throws NoSuchMethodException if no matching method is found. + */ + Method getMethod(String name, Type... parameterTypes) throws NoSuchMethodException; + + /** + * Gets the super class associated with the type. + * @return Super class type or null. + */ + Type getSuperclass(); + + /** + * Returns all the annotations of a given type. + * @param annotationClass The annotation type to search for. + * @param The annotation class. + * @return An array of found annotations. + */ + A[] getAnnotationsByType(Class annotationClass); + + /** + * Gets a specific annotation ignoring inherited annotations. + * @param annotationClass The class to search for. + * @param The class to search for. + * @return The declared annotation or null. + */ + A getDeclaredAnnotation(Class annotationClass); + + /** + * Gets a specific annotation including inherited annotations. + * @param annotationClass The class to search for. + * @param The class to search for. + * @return The declared annotation or null. + */ + A getAnnotation(Class annotationClass); + + /** + * Searches for a specific annotation including inherited annotations. + * @param annotationClass The class to search for. + * @return true or false. + */ + boolean isAnnotationPresent(Class annotationClass); + + /** + * Can this type be assigned to another type. + * @param cls The type to assign to. + * @return true or false. + */ + boolean isAssignableFrom(Type cls); + + /** + * Is this type a Java primitive? + * @return true or false. + */ + boolean isPrimitive(); + + /** + * Gets the package this type belongs to. + * @return the package. + */ + Package getPackage(); + + /** + * Gets all the methods found in this type. + * @return Any array of methods. + */ + Method[] getMethods(); + + /** + * Gets all the methods found in this type. + * @return Any array of methods. + */ + Method[] getDeclaredMethods(); + + /** + * Gets all the fields found in this type. + * @return Any array of fields. + */ + Field[] getFields(); + + /** + * Gets all the fields found in this type ignoring inherited fields. + * @return Any array of fields. + */ + Field[] getDeclaredFields(); + + /** + * Gets a specific field found in this type ignoring inherited fields. + * @param name The name of the field to find. + * @return The found field. + * @throws NoSuchFieldException if the field is not found. + */ + Field getDeclaredField(String name) throws NoSuchFieldException; + + /** + * Returns the full list of type constructors. + * @return An array of type constructors. + */ + Method[] getConstructors(); + + /** + * Whether or not this type is parameterized. + * @return true or false. + */ + default boolean isParameterized() { + return false; + } + + /** + * Whether or not this type inherits from another type. + * @return true or false. + */ + boolean hasSuperType(); + + /** + * Constructs a new instance of the type. + * @return Thew newly construct instance. + * @throws InstantiationException If construction failed. + * @throws IllegalAccessException If the constructor could not be called. + */ + T newInstance() throws InstantiationException, IllegalAccessException; + + /** + * If this type represents an enumeration. + * @return true or false. + */ + boolean isEnum(); + + /** + * Returns the list of enumeration constants this type represents. + * @return An array of enumeration constants. + */ + T[] getEnumConstants(); + + /** + * If this type represents a static Java Class, return the underlying class. + * @return The Java class or empty. + */ + Optional> getUnderlyingClass(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/ClassScanner.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/ClassScanner.java new file mode 100644 index 0000000000..8fa63ead14 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/ClassScanner.java @@ -0,0 +1,68 @@ +/* + * 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.utils; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Set; + +/** + * Scans a package for classes by looking at files in the classpath. + */ +public interface ClassScanner { + + /** + * Scans all classes accessible from the context class loader which belong to the given package and subpackages. + * + * @param toScan package to scan + * @param annotation Annotation to search + * @return The classes + */ + Set> getAnnotatedClasses(Package toScan, Class annotation); + + /** + * Scans all classes accessible from the context class loader which belong to the given package and subpackages. + * + * @param packageName package name to scan. + * @param annotation Annotation to search + * @return The classes + */ + Set> getAnnotatedClasses(String packageName, Class annotation); + + /** + * Scans all classes accessible from the context class loader which belong to the current class loader. + * Filters the final output based on expression. + * @param annotations One or more annotation to search for + * @param filter filter expression to include the final results in the output. + * @return The classes + */ + Set> getAnnotatedClasses(List> annotations, FilterExpression filter); + + /** + * Scans all classes accessible from the context class loader which belong to the current class loader. + * + * @param annotations One or more annotation to search for + * @return The classes + */ + Set> getAnnotatedClasses(List> annotations); + + Set> getAnnotatedClasses(Class ...annotations); + + /** + * Returns all classes within a package. + * @param packageName The root package to search. + * @return All the classes within a package. + */ + Set> getAllClasses(String packageName); + + /** + * Function which will be invoked for deciding to include the class in final results. + */ + @FunctionalInterface + interface FilterExpression { + boolean include(Class clazz); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/DefaultClassScanner.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/DefaultClassScanner.java new file mode 100644 index 0000000000..92e1bf9dcc --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/DefaultClassScanner.java @@ -0,0 +1,142 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.utils; + +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.SecurityCheck; +import com.yahoo.elide.core.utils.coerce.converters.ElideTypeConverter; +import io.github.classgraph.ClassGraph; +import io.github.classgraph.ClassInfo; +import io.github.classgraph.ScanResult; + +import java.lang.annotation.Annotation; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Scans a package for classes by looking at files in the classpath. + */ +public class DefaultClassScanner implements ClassScanner { + + /** + * Class Scanning is terribly costly for service boot, so we do all the scanning up front once to + * save on startup costs. All Annotations Elide scans for must be listed here: + */ + private static final String [] CACHE_ANNOTATIONS = { + //Elide Core Annotations + Include.class.getCanonicalName(), + SecurityCheck.class.getCanonicalName(), + ElideTypeConverter.class.getCanonicalName(), + + //GraphQL annotations. Strings here to avoid dependency. + "com.yahoo.elide.graphql.subscriptions.annotations.Subscription", + + //Aggregation Store Annotations. Strings here to avoid dependency. + "com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable", + "com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery", + "org.hibernate.annotations.Subselect", + + //JPA + "javax.persistence.Entity", + "javax.persistence.Table" + }; + + private final Map>> startupCache; + + /** + * Primarily for tests so builds don't take forever. + */ + private static DefaultClassScanner _instance; + + /** + * For use within a container where class scanning happens at compile time. + * @param startupCache Maps annotations (in CACHE_ANNOTATIONS) to classes. + */ + public DefaultClassScanner(Map>> startupCache) { + this.startupCache = startupCache; + } + + /** + * For use within a container where class scanning happens at boot time. + */ + public DefaultClassScanner() { + this.startupCache = new HashMap<>(); + try (ScanResult scanResult = new ClassGraph().enableClassInfo().enableAnnotationInfo().scan()) { + for (String annotationName : CACHE_ANNOTATIONS) { + startupCache.put(annotationName, scanResult.getClassesWithAnnotation(annotationName) + .stream() + .map(ClassInfo::loadClass) + .collect(Collectors.toCollection(LinkedHashSet::new))); + + } + } + } + + @Override + public Set> getAnnotatedClasses(Package toScan, Class annotation) { + return getAnnotatedClasses(toScan.getName(), annotation); + } + + @Override + public Set> getAnnotatedClasses(String packageName, Class annotation) { + return startupCache.get(annotation.getCanonicalName()).stream() + .filter(clazz -> + clazz.getPackage().getName().equals(packageName) + || clazz.getPackage().getName().startsWith(packageName + ".")) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @Override + public Set> getAnnotatedClasses(List> annotations, + FilterExpression filter) { + Set> result = new LinkedHashSet<>(); + + for (Class annotation : annotations) { + result.addAll(startupCache.get(annotation.getCanonicalName()).stream() + .filter(filter::include) + .collect(Collectors.toCollection(LinkedHashSet::new))); + } + + return result; + } + + @Override + public Set> getAnnotatedClasses(List> annotations) { + return getAnnotatedClasses(annotations, clazz -> true); + } + + @Override + public Set> getAnnotatedClasses(Class ...annotations) { + return getAnnotatedClasses(Arrays.asList(annotations)); + } + + @Override + public Set> getAllClasses(String packageName) { + try (ScanResult scanResult = new ClassGraph() + .enableClassInfo().whitelistPackages(packageName).scan()) { + return scanResult.getAllClasses().stream() + .map((ClassInfo::loadClass)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + } + + /** + * Primarily for tests to only create a single instance of this to reduce build times. Production code + * will use DI to accomplish the same. + * @return The single instance. + */ + public static synchronized DefaultClassScanner getInstance() { + if (_instance == null) { + _instance = new DefaultClassScanner(); + } + return _instance; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/TimedFunction.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/TimedFunction.java new file mode 100644 index 0000000000..2fc5e965d4 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/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.utils; + +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/utils/TypeHelper.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/TypeHelper.java new file mode 100644 index 0000000000..e84f4ad767 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/TypeHelper.java @@ -0,0 +1,155 @@ +/* + * 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.utils; + +import static org.apache.commons.lang3.StringUtils.isEmpty; + +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Dynamic; +import com.yahoo.elide.core.type.Type; + +import com.google.common.collect.Sets; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 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 + * + * @param type type to check + * @return True is the type is primitive number type + */ + public static boolean isPrimitiveNumberType(Type type) { + if (type instanceof Dynamic) { + return false; + } + + return PRIMITIVE_NUMBER_TYPES.contains(((ClassType) type).getCls()); + } + + /** + * 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] + * @param dictionary The entity dictionary + * @return extended type alias, e.g. a_b_c + */ + public static String extendTypeAlias(String alias, Path extension, EntityDictionary dictionary) { + String result = alias; + List elements = extension.getPathElements(); + + for (int i = 0; i < elements.size() - 1; i++) { + Path.PathElement next = elements.get(i); + if (dictionary.isComplexAttribute(next.getType(), next.getFieldName())) { + result = result + "." + next.getFieldName(); + } else { + 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 + * @param dictionary The entity dictionary + * @return relationship path alias, i.e. foo.bar.baz would be foo_bar + */ + public static String getPathAlias(Path path, EntityDictionary dictionary) { + return extendTypeAlias(getTypeAlias(path.getPathElements().get(0).getType()), path, dictionary); + } + + /** + * 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 isEmpty(parentAlias) + ? fieldName + : isEmpty(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(Type 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 + * @param dictionary the entity dictionary + * @return combined alias + */ + public static String getFieldAlias(Path path, String fieldName, EntityDictionary dictionary) { + return getFieldAlias(getPathAlias(path, dictionary), 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 isEmpty(tableAlias) ? fieldName : tableAlias + PERIOD + fieldName; + } + + /** + * Converts a Set of classes to a set of types. + * @param cls The set of classes. + * @return A new set of types. + */ + public static Set> getClassType(Set> cls) { + return cls.stream() + .map(ClassType::of) + .collect(Collectors.toSet()); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/BidirectionalConvertUtilBean.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/BidirectionalConvertUtilBean.java new file mode 100644 index 0000000000..0c661a47fe --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/BidirectionalConvertUtilBean.java @@ -0,0 +1,37 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.utils.coerce; + +import org.apache.commons.beanutils.ConvertUtilsBean; +import org.apache.commons.beanutils.Converter; +import org.apache.commons.lang3.tuple.Pair; + +import java.util.HashMap; +import java.util.Map; + +/** + * Allows registration based on source and target type (rather than just the target type). + */ +public class BidirectionalConvertUtilBean extends ConvertUtilsBean { + protected Map, Class>, Converter> bidirectionalConverters = new HashMap<>(); + + public void register(Class sourceType, Class targetType, Converter converter) { + Pair, Class> key = Pair.of(sourceType, targetType); + + bidirectionalConverters.put(key, converter); + } + + @Override + public Converter lookup(Class sourceType, Class targetType) { + Pair, Class> key = Pair.of(sourceType, targetType); + + if (bidirectionalConverters.containsKey(key)) { + return bidirectionalConverters.get(key); + } + + return super.lookup(sourceType, targetType); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/CoerceUtil.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/CoerceUtil.java new file mode 100644 index 0000000000..5b5585558c --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/CoerceUtil.java @@ -0,0 +1,148 @@ +/* + * 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.utils.coerce; + +import static com.yahoo.elide.core.utils.TypeHelper.isNumberType; +import com.yahoo.elide.core.exceptions.InvalidAttributeException; +import com.yahoo.elide.core.exceptions.InvalidValueException; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.coerce.converters.FromMapConverter; +import com.yahoo.elide.core.utils.coerce.converters.Serde; +import com.yahoo.elide.core.utils.coerce.converters.ToEnumConverter; +import com.yahoo.elide.core.utils.coerce.converters.ToUUIDConverter; +import com.google.common.base.Preconditions; +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.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Class for coercing a value to a target class. + */ +public class CoerceUtil { + + private static final ToEnumConverter TO_ENUM_CONVERTER = new ToEnumConverter(); + private static final ToUUIDConverter TO_UUID_CONVERTER = new ToUUIDConverter(); + private static final FromMapConverter FROM_MAP_CONVERTER = new FromMapConverter(); + private static final Map, Serde> SERDES = new LinkedHashMap<>(); + private static final BeanUtilsBean BEAN_UTILS_BEAN_INSTANCE = setup(); + private static final Set INITIALIZED_CLASSLOADERS = + Collections.newSetFromMap(new MapMaker().weakKeys().makeMap()); + + public static T coerce(Object value, Type type) { + Preconditions.checkState(type instanceof ClassType); + Class cls = ((ClassType) type).getCls(); + return coerce(value, cls); + } + + /** + * Convert value to target class. + * + * @param type + * @param value value to convert + * @param cls class to convert to + * @return coerced value + */ + 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; + } + + try { + return (T) ConvertUtils.convert(value, cls); + } catch (ConversionException | InvalidAttributeException | IllegalArgumentException e) { + throw new InvalidValueException(value, e.getMessage()); + } + } + + public static void register(Class targetType, Serde serde) { + initializeCurrentClassLoaderIfNecessary(); + + SERDES.put(targetType, serde); + ConvertUtils.register(new Converter() { + + @Override + public T convert(Class aClass, Object o) { + return (T) serde.deserialize((S) o); + } + + }, targetType); + } + + public static Serde lookup(Class targetType) { + return (Serde) SERDES.getOrDefault(targetType, null); + } + + public static Map, Serde> getSerdes() { + return Collections.unmodifiableMap(SERDES); + } + + /** + * Perform CoerceUtil setup. + */ + private static BeanUtilsBean setup() { + return new BeanUtilsBean(new BidirectionalConvertUtilBean() { + { + // https://github.com/yahoo/elide/issues/260 + // enable throwing exceptions when conversion fails + register(true, false, 0); + register(TO_UUID_CONVERTER, UUID.class); + } + + @Override + /* + * Overriding lookup to execute enum converter if target is enum + * or map convert if source is map + */ + public Converter lookup(Class sourceType, Class targetType) { + if (targetType.isEnum()) { + return TO_ENUM_CONVERTER; + } + if (Map.class.isAssignableFrom(sourceType)) { + return FROM_MAP_CONVERTER; + } + return super.lookup(sourceType, targetType); + } + }); + } + + /** + * Initialize this classloader if necessary + */ + private static void initializeCurrentClassLoaderIfNecessary() { + ClassLoader currentClassLoader = Thread.currentThread().getContextClassLoader(); + if (INITIALIZED_CLASSLOADERS.contains(currentClassLoader)) { + return; + } + BeanUtilsBean.setInstance(BEAN_UTILS_BEAN_INSTANCE); + markClassLoaderAsInitialized(currentClassLoader); + } + + /** + * Mark the current class loader as initialized. + * @param currentClassLoader current ClassLoader + */ + private static void markClassLoaderAsInitialized(ClassLoader currentClassLoader) { + INITIALIZED_CLASSLOADERS.add(currentClassLoader); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ElideTypeConverter.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ElideTypeConverter.java new file mode 100644 index 0000000000..fd43aa6c49 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ElideTypeConverter.java @@ -0,0 +1,20 @@ +/* + * 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.utils.coerce.converters; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface ElideTypeConverter { + Class type(); + String name(); + String description() default "Custom Elide type"; + Class [] subTypes() default {}; +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/EpochToDateConverter.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/EpochToDateConverter.java new file mode 100644 index 0000000000..6bdf071bfa --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/EpochToDateConverter.java @@ -0,0 +1,73 @@ +/* + * 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.utils.coerce.converters; + +import com.yahoo.elide.core.exceptions.InvalidAttributeException; +import org.apache.commons.beanutils.Converter; +import org.apache.commons.lang3.ClassUtils; + +import java.sql.Time; +import java.sql.Timestamp; +import java.util.Date; + + +/** + * Convert epoch(in string or long) to Date. + * @param Date Type + */ +public class EpochToDateConverter implements Converter, Serde { + + private Class targetType; + + public EpochToDateConverter(Class targetType) { + this.targetType = targetType; + } + + @Override + public T convert(Class cls, Object value) { + try { + if (ClassUtils.isAssignable(value.getClass(), String.class)) { + return stringToDate(cls, (String) value); + } + if (ClassUtils.isAssignable(value.getClass(), Number.class, true)) { + return numberToDate(cls, (Number) value); + } + throw new UnsupportedOperationException(value.getClass().getSimpleName() + " is not a valid epoch"); + } catch (IndexOutOfBoundsException | UnsupportedOperationException | IllegalArgumentException e) { + throw new InvalidAttributeException("Unknown " + cls.getSimpleName() + " value " + value, e); + } + } + + @Override + public T deserialize(Object val) { + return convert(targetType, val); + } + + @Override + public Object serialize(T val) { + return val.getTime(); + } + + private static T numberToDate(Class cls, Number epoch) { + if (ClassUtils.isAssignable(cls, java.sql.Date.class)) { + return cls.cast(new java.sql.Date(epoch.longValue())); + } + if (ClassUtils.isAssignable(cls, Timestamp.class)) { + return cls.cast(new Timestamp(epoch.longValue())); + } + if (ClassUtils.isAssignable(cls, Time.class)) { + return cls.cast(new Time(epoch.longValue())); + } + if (ClassUtils.isAssignable(cls, Date.class)) { + return cls.cast(new Date(epoch.longValue())); + } + throw new UnsupportedOperationException("Cannot convert to " + cls.getSimpleName()); + } + + private static T stringToDate(Class cls, String epoch) { + return numberToDate(cls, Long.parseLong(epoch)); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/FromMapConverter.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/FromMapConverter.java new file mode 100644 index 0000000000..095c996cf4 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/FromMapConverter.java @@ -0,0 +1,29 @@ +/* + * 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.utils.coerce.converters; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.beanutils.Converter; + +/** + * Uses Jackson to Convert from Map to target object. + */ +public class FromMapConverter implements Converter { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * Convert value from map to target object. + * + * @param cls class to convert to + * @param value value to convert + * @param object type + * @return converted object + */ + @Override + public T convert(Class cls, Object value) { + return MAPPER.convertValue(value, cls); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ISO8601DateSerde.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ISO8601DateSerde.java new file mode 100644 index 0000000000..2d7a356a51 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ISO8601DateSerde.java @@ -0,0 +1,75 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.utils.coerce.converters; + +import org.apache.commons.lang3.ClassUtils; +import org.apache.commons.lang3.time.FastDateFormat; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +/** + * Serializes ISO8601 Dates to Strings and vice versa. + */ +public class ISO8601DateSerde implements Serde { + + protected FastDateFormat df; + protected Class targetType; + + public ISO8601DateSerde(SimpleDateFormat df) { + this(df, Date.class); + } + + public ISO8601DateSerde(SimpleDateFormat df, Class targetType) { + this(df.toPattern(), df.getTimeZone(), targetType); + } + + public ISO8601DateSerde(String formatString, TimeZone tz) { + this(formatString, tz, Date.class); + } + + public ISO8601DateSerde(String formatString, TimeZone tz, Class targetType) { + this.df = FastDateFormat.getInstance(formatString, tz); + this.targetType = targetType; + } + + public ISO8601DateSerde() { + this ("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")); + } + + @Override + public Date deserialize(Object val) { + Date date; + + try { + if (val instanceof Date) { + date = (Date) val; + } + else { + date = df.parse(val.toString()); + } + } catch (java.text.ParseException e) { + throw new IllegalArgumentException("Date strings must be formatted as " + df.getPattern()); + } + + if (ClassUtils.isAssignable(targetType, java.sql.Date.class)) { + return new java.sql.Date(date.getTime()); + } + if (ClassUtils.isAssignable(targetType, java.sql.Timestamp.class)) { + return new java.sql.Timestamp(date.getTime()); + } + if (ClassUtils.isAssignable(targetType, java.sql.Time.class)) { + return new java.sql.Time(date.getTime()); + } + return date; + } + + @Override + public String serialize(Date val) { + return df.format(val); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/InstantSerde.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/InstantSerde.java new file mode 100644 index 0000000000..2e43310457 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/InstantSerde.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.core.utils.coerce.converters; + +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * Convert an Instant to/from an ISO-8601 string representation. + * + * Uses the semantics of {@link java.time.format.DateTimeFormatter#ISO_INSTANT} + */ +public class InstantSerde implements Serde { + @Override + public Instant deserialize(final String value) { + try { + return Instant.from( + // NB. ideally we would use ISO_INSTANT here but there is a bug in JDK-8 that + // means that parsing an ISO offset time doesn't work :-( + // https://bugs.openjdk.java.net/browse/JDK-8166138 + DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(value) + ); + } catch (final DateTimeParseException ex) { + // Translate parsing exception to something CoerceUtil will handle appropriately + throw new IllegalArgumentException(ex); + } + } + + @Override + public String serialize(final Instant value) { + return DateTimeFormatter.ISO_INSTANT.format(value); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/OffsetDateTimeSerde.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/OffsetDateTimeSerde.java new file mode 100644 index 0000000000..65a2d248bc --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/OffsetDateTimeSerde.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.core.utils.coerce.converters; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * Serde class for bidirectional conversion from OffsetDateTime type to String. + */ +public class OffsetDateTimeSerde implements Serde { + + @Override + public OffsetDateTime deserialize(String val) { + try { + return OffsetDateTime.parse(val, DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } catch (final DateTimeParseException ex) { + // Translate parsing exception to something CoerceUtil will handle appropriately + throw new IllegalArgumentException(ex); + } + } + + @Override + public String serialize(OffsetDateTime val) { + return val.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/Serde.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/Serde.java new file mode 100644 index 0000000000..daf0fb4bf7 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/Serde.java @@ -0,0 +1,27 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.utils.coerce.converters; + +/** + * Bidirectional conversion from one type to another. + * @param The serialized type + * @param The deserialized type + */ +public interface Serde { + /** + * Deserialize an instance of type S to type T. + * @param val The thing to deserialize + * @return The deserialized value + */ + T deserialize(S val); + + /** + * Serializes an instance of type T as type S. + * @param val The thing to serialize + * @return The serialized value + */ + S serialize(T val); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/TimeZoneSerde.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/TimeZoneSerde.java new file mode 100644 index 0000000000..21aea0fba4 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/TimeZoneSerde.java @@ -0,0 +1,24 @@ +/* + * 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.utils.coerce.converters; + +import java.util.TimeZone; + +/** + * Serde class for bidirectional conversion from TimeZone type to String. + */ +public class TimeZoneSerde implements Serde { + + @Override + public TimeZone deserialize(String val) { + return TimeZone.getTimeZone(val); + } + + @Override + public String serialize(TimeZone val) { + return val.getDisplayName(false, TimeZone.SHORT); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/utils/coerce/converters/ToEnumConverter.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToEnumConverter.java similarity index 81% rename from elide-core/src/main/java/com/yahoo/elide/utils/coerce/converters/ToEnumConverter.java rename to elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToEnumConverter.java index da3a3f9133..c11d4f8d46 100644 --- a/elide-core/src/main/java/com/yahoo/elide/utils/coerce/converters/ToEnumConverter.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToEnumConverter.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.utils.coerce.converters; +package com.yahoo.elide.core.utils.coerce.converters; import com.yahoo.elide.core.exceptions.InvalidAttributeException; import org.apache.commons.beanutils.Converter; @@ -27,11 +27,14 @@ public T convert(Class cls, Object value) { try { if (ClassUtils.isAssignable(value.getClass(), String.class)) { return stringToEnum(cls, (String) value); - } else if (ClassUtils.isAssignable(value.getClass(), Integer.class, true)) { + } + if (ClassUtils.isAssignable(value.getClass(), Integer.class, true)) { return intToEnum(cls, (Integer) value); - } else { - throw new UnsupportedOperationException(value.getClass().getSimpleName() + " to Enum no supported"); } + if (ClassUtils.isAssignable(value.getClass(), Long.class, true)) { + return intToEnum(cls, ((Long) value).intValue()); + } + throw new UnsupportedOperationException(value.getClass().getSimpleName() + " to Enum no supported"); } catch (IndexOutOfBoundsException | ReflectiveOperationException | UnsupportedOperationException | IllegalArgumentException e) { throw new InvalidAttributeException("Unknown " + cls.getSimpleName() + " value " + value, e); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToUUIDConverter.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToUUIDConverter.java new file mode 100644 index 0000000000..935b66979c --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToUUIDConverter.java @@ -0,0 +1,31 @@ +/* + * 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.utils.coerce.converters; + +import org.apache.commons.beanutils.Converter; + +import java.util.UUID; + +/** + * Converter to UUID. + */ +public class ToUUIDConverter implements Converter { + /** + * Convert value to UUID. + * + * @param cls class to convert to + * @param value value to convert + * @param object type + * @return converted object + */ + @Override + public T convert(Class cls, Object value) { + if (cls == UUID.class) { + return (T) UUID.fromString(String.valueOf(value)); + } + throw new UnsupportedOperationException("Cannot convert to " + cls.getSimpleName()); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/URLSerde.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/URLSerde.java new file mode 100644 index 0000000000..2038a96166 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/URLSerde.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.utils.coerce.converters; + +import com.yahoo.elide.core.exceptions.InvalidValueException; + +import java.net.MalformedURLException; +import java.net.URL; + +public class URLSerde implements Serde { + + @Override + public URL deserialize(String val) { + URL url; + try { + url = new URL(val); + } catch (MalformedURLException e) { + throw new InvalidValueException("Invalid URL " + val); + } + return url; + } + + @Override + public String serialize(URL val) { + return val.toString(); + } +} 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 deleted file mode 100644 index c0c017154c..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/extensions/PatchRequestScope.java +++ /dev/null @@ -1,56 +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.extensions; - -import com.yahoo.elide.audit.AuditLogger; -import com.yahoo.elide.core.DataStoreTransaction; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.jsonapi.JsonApiMapper; -import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import com.yahoo.elide.security.PermissionExecutor; -import com.yahoo.elide.security.SecurityMode; -import com.yahoo.elide.security.User; - -import java.util.function.Function; - -/** - * Special request scope for Patch Extension. - */ -public class PatchRequestScope extends RequestScope { - - /** - * Outer RequestScope constructor for use by Patch Extension. - * - * @param path the URL path - * @param transaction current database transaction - * @param user request user - * @param dictionary entity dictionary - * @param mapper Json API mapper - * @param auditLogger the logger - */ - public PatchRequestScope( - String path, - DataStoreTransaction transaction, - User user, - EntityDictionary dictionary, - JsonApiMapper mapper, - AuditLogger auditLogger, - Function permissionExecutorGenerator) { - super(path, null, transaction, user, dictionary, mapper, auditLogger, SecurityMode.SECURITY_ACTIVE, - permissionExecutorGenerator); - } - - /** - * Inner RequestScope copy constructor for use by Patch Extension actions. - * - * @param jsonApiDocument document - * @param scope outer request scope - */ - public PatchRequestScope(String path, JsonApiDocument jsonApiDocument, PatchRequestScope scope) { - super(path, jsonApiDocument, scope); - } -} 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..26120ca26a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java @@ -0,0 +1,448 @@ +/* + * 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.Path; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.InvalidCollectionException; +import com.yahoo.elide.core.exceptions.InvalidValueException; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.pagination.PaginationImpl; +import com.yahoo.elide.core.request.Argument; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Pagination; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.core.request.Sorting; +import com.yahoo.elide.core.sort.SortingImpl; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.generated.parsers.CoreBaseVisitor; +import com.yahoo.elide.generated.parsers.CoreParser; +import com.yahoo.elide.jsonapi.parser.JsonApiParser; +import com.google.common.collect.Sets; +import org.apache.commons.collections4.CollectionUtils; +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.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; + } + + public 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(); + sparseFields = RequestScope.parseSparseFields(queryParams); + this.scope = scope; + } + + public EntityProjection parsePath(String path) { + return visit(JsonApiParser.parse(path)).apply(null).projection; + } + + public EntityProjection parseInclude(Type entityClass) { + return EntityProjection.builder() + .type(entityClass) + .arguments(getDefaultEntityArguments(entityClass)) + .relationships(toRelationshipSet(getIncludedRelationships(entityClass))) + .build(); + } + + @Override + public Function, NamedEntityProjection> visitRootCollectionLoadEntities( + CoreParser.RootCollectionLoadEntitiesContext ctx) { + return visitTerminalCollection(ctx.term(), true); + } + + @Override + public Function, NamedEntityProjection> visitSubCollectionReadCollection( + CoreParser.SubCollectionReadCollectionContext ctx) { + return visitTerminalCollection(ctx.term(), false); + } + + @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 -> ctx.entity().accept(this).apply(null); + } + + @Override + public Function, NamedEntityProjection> visitSubCollectionReadEntity( + CoreParser.SubCollectionReadEntityContext ctx) { + return parentClass -> ctx.entity().accept(this).apply(parentClass); + } + + @Override + public Function, NamedEntityProjection> visitRelationship(CoreParser.RelationshipContext ctx) { + return (parentClass) -> { + String entityName = ctx.term().getText(); + + Type 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) + .arguments(getDefaultEntityArguments(entityClass)) + .type(entityClass) + .build() + ).build(); + }; + } + + @Override + public Function, NamedEntityProjection> visitEntity(CoreParser.EntityContext ctx) { + return (parentClass) -> { + String entityName = ctx.term().getText(); + + Type entityClass = getEntityClass(parentClass, entityName); + + return NamedEntityProjection.builder() + .name(entityName) + .projection(EntityProjection.builder() + .type(entityClass) + .arguments(getDefaultEntityArguments(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; + } + return aggregate; + } + + public EntityProjection visitIncludePath(Path path) { + Path.PathElement pathElement = path.getPathElements().get(0); + int size = path.getPathElements().size(); + + Type 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) + .arguments(getDefaultEntityArguments(entityClass)) + .build(); + } + + return EntityProjection.builder() + .relationships(toRelationshipSet(getSparseRelationships(entityClass))) + .attributes(getSparseAttributes(entityClass)) + .type(entityClass) + .arguments(getDefaultEntityArguments(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(); + + Type entityClass = getEntityClass(parentClass, entityName); + + NamedEntityProjection projection = subCollection.accept(this).apply(entityClass); + + return NamedEntityProjection.builder() + .name(entityName) + .projection(EntityProjection.builder() + .type(entityClass) + .arguments(getDefaultEntityArguments(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(); + + Type 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) + .arguments(getDefaultEntityArguments(entityClass)) + .filterExpression(filter) + .relationships(toRelationshipSet(getRequiredRelationships(entityClass))) + .relationship(relationshipName, relationshipProjection.projection) + .build() + ).build(); + }; + } + + private Function, NamedEntityProjection> visitTerminalCollection(CoreParser.TermContext collectionName, + boolean isRoot) { + return (parentClass) -> { + String collectionNameText = collectionName.getText(); + + Type entityClass = getEntityClass(parentClass, collectionNameText); + + if (isRoot && !dictionary.isRoot(entityClass)) { + throw new InvalidCollectionException(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)) + .arguments(getDefaultEntityArguments(entityClass)) + .type(entityClass) + .build() + ).build(); + }; + } + + private Type getEntityClass(Type parentClass, String entityLabel) { + + //entityLabel represents a root collection. + if (parentClass == null) { + + Type 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(Type 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 getDefaultAttributeArguments(Type entityClass, String attributeName) { + return dictionary.getAttributeArguments(entityClass, attributeName) + .stream() + .map(argumentType -> { + return Argument.builder() + .name(argumentType.getName()) + .value(argumentType.getDefaultValue()) + .build(); + }) + .collect(Collectors.toSet()); + } + + private Set getDefaultEntityArguments(Type entityClass) { + return dictionary.getEntityArguments(entityClass) + .stream() + .map(argumentType -> { + return Argument.builder() + .name(argumentType.getName()) + .value(argumentType.getDefaultValue()) + .build(); + }) + .collect(Collectors.toSet()); + } + + private Set getSparseAttributes(Type entityClass) { + Set allAttributes = new LinkedHashSet<>(dictionary.getAttributes(entityClass)); + + Set sparseFieldsForEntity = sparseFields.get(dictionary.getJsonAliasFor(entityClass)); + if (CollectionUtils.isEmpty(sparseFieldsForEntity)) { + sparseFieldsForEntity = allAttributes; + } else { + Set allRelationships = new LinkedHashSet<>(dictionary.getRelationships(entityClass)); + validateSparseFields(sparseFieldsForEntity, allAttributes, allRelationships, entityClass); + sparseFieldsForEntity = Sets.intersection(allAttributes, sparseFieldsForEntity); + } + + return sparseFieldsForEntity.stream() + .map(attributeName -> Attribute.builder() + .name(attributeName) + .type(dictionary.getType(entityClass, attributeName)) + .arguments(getDefaultAttributeArguments(entityClass, attributeName)) + .build()) + .collect(Collectors.toSet()); + } + + private Map getSparseRelationships(Type entityClass) { + Set allRelationships = new LinkedHashSet<>(dictionary.getRelationships(entityClass)); + Set sparseFieldsForEntity = sparseFields.get(dictionary.getJsonAliasFor(entityClass)); + + if (CollectionUtils.isEmpty(sparseFieldsForEntity)) { + sparseFieldsForEntity = allRelationships; + } else { + Set allAttributes = new LinkedHashSet<>(dictionary.getAttributes(entityClass)); + validateSparseFields(sparseFieldsForEntity, allAttributes, allRelationships, entityClass); + 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)) + .arguments(getDefaultEntityArguments(entityClass)) + .filterExpression(filter) + .build(); + } + )); + } + + private void validateSparseFields(Set sparseFieldsForEntity, Set allAttributes, + Set allRelationships, Type entityClass) { + String unknownSparseFields = sparseFieldsForEntity.stream() + .filter(field -> !(allAttributes.contains(field) || allRelationships.contains(field))) + .collect(Collectors.joining(", ")); + + if (!unknownSparseFields.isEmpty()) { + throw new InvalidValueException(String.format("%s does not contain the fields: [%s]", + dictionary.getJsonAliasFor(entityClass), unknownSparseFields)); + } + } + + private Map getRequiredRelationships(Type 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(Type 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/JsonApiMapper.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java index 9b3c0edcd3..0d0f16427e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java @@ -5,13 +5,12 @@ */ package com.yahoo.elide.jsonapi; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Patch; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import com.yahoo.elide.jsonapi.models.Patch; import java.io.IOException; import java.util.List; @@ -23,24 +22,21 @@ public class JsonApiMapper { private final ObjectMapper mapper; /** - * Instantiates a new JSON API OBJECT_MAPPER. - * - * @param dictionary the dictionary + * Instantiates a new Json Api Mapper. */ - public JsonApiMapper(EntityDictionary dictionary) { - mapper = new ObjectMapper(); - mapper.registerModule(JsonApiSerializer.getModule(dictionary)); + public JsonApiMapper() { + this.mapper = new ObjectMapper(); + mapper.registerModule(JsonApiSerializer.getModule()); } /** * Instantiates a new Json Api Mapper. * - * @param dictionary the dictionary * @param mapper Custom object mapper to use internally for serializing/deserializing */ - public JsonApiMapper(EntityDictionary dictionary, ObjectMapper mapper) { + public JsonApiMapper(ObjectMapper mapper) { this.mapper = mapper; - mapper.registerModule(JsonApiSerializer.getModule(dictionary)); + mapper.registerModule(JsonApiSerializer.getModule()); } /** diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiSerializer.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiSerializer.java index 3d4c3e3d74..bc1e8d65c2 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiSerializer.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiSerializer.java @@ -11,7 +11,6 @@ import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import com.yahoo.elide.core.EntityDictionary; import java.io.IOException; import java.util.Set; @@ -25,14 +24,14 @@ public class JsonApiSerializer extends StdSerializer { private final Class type; - JsonApiSerializer(Class type, EntityDictionary dictionary) { + JsonApiSerializer(Class type) { super(type); this.type = type; } - public static Module getModule(EntityDictionary dictionary) { + public static Module getModule() { SimpleModule jsonApiModule = new SimpleModule("JsonApiModule", new Version(1, 0, 0, null, null, null)); - jsonApiModule.addSerializer(new JsonApiSerializer<>(Set.class, dictionary)); + jsonApiModule.addSerializer(new JsonApiSerializer<>(Set.class)); return jsonApiModule; } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/DocumentProcessor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/DocumentProcessor.java index e7a131a21b..6c8c442c51 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/DocumentProcessor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/DocumentProcessor.java @@ -8,9 +8,7 @@ import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import java.util.Optional; import java.util.Set; - import javax.ws.rs.core.MultivaluedMap; /** @@ -28,7 +26,7 @@ public interface DocumentProcessor { * @param queryParams the query params */ void execute(JsonApiDocument jsonApiDocument, PersistentResource resource, - Optional> queryParams); + MultivaluedMap queryParams); /** * A method for making transformations to the JsonApiDocument. @@ -38,7 +36,7 @@ void execute(JsonApiDocument jsonApiDocument, PersistentResource resource, * @param queryParams the query params */ void execute(JsonApiDocument jsonApiDocument, Set resources, - Optional> queryParams); + MultivaluedMap queryParams); //TODO Possibly add a something like a 'afterExecute' method to process after the first round of execution } 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 085d176e56..0f1283d93f 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 @@ -5,16 +5,20 @@ */ package com.yahoo.elide.jsonapi.document.processors; -import com.google.common.collect.Lists; import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.exceptions.ForbiddenAccessException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.jsonapi.EntityProjectionMaker; import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.google.common.collect.Lists; -import javax.ws.rs.core.MultivaluedMap; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; -import java.util.Optional; import java.util.Set; +import javax.ws.rs.core.MultivaluedMap; /** * A Document Processor that add requested relations to the include block of the JsonApiDocument. @@ -30,9 +34,9 @@ public class IncludedProcessor implements DocumentProcessor { */ @Override public void execute(JsonApiDocument jsonApiDocument, PersistentResource resource, - Optional> queryParams) { + MultivaluedMap queryParams) { if (isPresent(queryParams, INCLUDE)) { - addIncludedResources(jsonApiDocument, resource, queryParams.get().get(INCLUDE)); + addIncludedResources(jsonApiDocument, resource, queryParams.get(INCLUDE)); } } @@ -42,12 +46,12 @@ public void execute(JsonApiDocument jsonApiDocument, PersistentResource resource */ @Override public void execute(JsonApiDocument jsonApiDocument, Set resources, - Optional> queryParams) { + MultivaluedMap queryParams) { if (isPresent(queryParams, INCLUDE)) { // Process include for each resource resources.forEach(resource -> - addIncludedResources(jsonApiDocument, resource, queryParams.get().get(INCLUDE))); + addIncludedResources(jsonApiDocument, resource, queryParams.get(INCLUDE))); } } @@ -56,13 +60,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.getResourceType()); // 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); }); }); } @@ -72,23 +79,34 @@ 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); - rec.getRelationCheckedFiltered(relation).forEach(resource -> { + Set collection; + Relationship relationship = projection.getRelationship(relation).orElseThrow(IllegalStateException::new); + try { + collection = rec.getRelationCheckedFiltered(relationship).toList(LinkedHashSet::new).blockingGet(); + + } catch (ForbiddenAccessException e) { + return; + } + + collection.forEach(resource -> { jsonApiDocument.addIncluded(resource.toResource()); //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()); } }); } - private static boolean isPresent(Optional> queryParams, String key) { - return queryParams.isPresent() && queryParams.get().get(key) != null; + private static boolean isPresent(MultivaluedMap queryParams, String key) { + return queryParams != null && queryParams.get(key) != null; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/SortProcessor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/SortProcessor.java deleted file mode 100644 index 26d90abe64..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/SortProcessor.java +++ /dev/null @@ -1,164 +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.jsonapi.document.processors; - -import com.yahoo.elide.core.PersistentResource; -import com.yahoo.elide.jsonapi.models.Data; -import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import com.yahoo.elide.jsonapi.models.Resource; -import com.google.common.collect.Ordering; - -import java.util.Comparator; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -import javax.ws.rs.core.MultivaluedMap; - -/** - * Implementation for JSON API 'sort' query param. - * - * Sorts a JsonApiDocument's data field based on attributes specified in query param. - * If an attribute is not found, it will be ignored for sorting. - * Null values are sorted first. - * - * Supports sort by multiple fields by separating them with ',' - * Supports descending sorts by prefixing the field with '-' - * Does not support sorting by a relationship's field (ex: author.name) - * - * Example: - * /posts?sort=title,-created - */ -public class SortProcessor implements DocumentProcessor { - - /** - * The constant SORT_PARAM. - */ - public static final String SORT_PARAM = "sort"; - - /** - * The constant DESCENDING_TOKEN. - */ - public static final char DESCENDING_TOKEN = '-'; - - /** - * Sorts a JsonApiDocument's data field based on attributes specified in the 'sort' query param. - * This method is a no-op as a single record is already sorted. - * - * @param jsonApiDocument the json api document - * @param resource the resource - * @param queryParams the query params - */ - @Override - public void execute(JsonApiDocument jsonApiDocument, PersistentResource resource, - Optional> queryParams) { - // NO-OP - } - - /** - * Sorts a JsonApiDocument's data field based on attributes specified in the 'sort' query param. - * - * @param jsonApiDocument the json api document - * @param resources the resources - * @param queryParams the query params - */ - @Override - public void execute(JsonApiDocument jsonApiDocument, Set resources, - Optional> queryParams) { - - // Only sort if requested by query param - queryParams.filter(params -> params.containsKey(SORT_PARAM)).ifPresent(params -> { - List sortFields = params.get(SORT_PARAM); - - // Sort the json api document's data property based on the requested sort fields - sort(jsonApiDocument.getData(), sortFields); - }); - - } - - /** - * Sort data based on provided sort field list. - * - * @param data resource data to sort - * @param sortFields - attribute fields within the data to sort by - */ - private void sort(Data data, List sortFields) { - List> comparisonFunctions = buildComparisonFunctions(sortFields); - - data.sort((a, b) -> - - // Apply comparison functions in order until one returns a non-zero value - Ordering.compound(comparisonFunctions).compare(a, b) - ); - } - - /** - * Build list of comparators. - * - * @param sortFields attribute fields within the data to sort by - * @return list of comparators to do the sorting - */ - private List> buildComparisonFunctions(List sortFields) { - // Convert sort fields to comparisons - return sortFields.stream() - .map(this::comparisonForField) - .collect(Collectors.toList()); - } - - /** - * Field comparator. - * - * @param field name of attribute field to compare, prefix with '-' to specify descending order - * @return a comparison between resources for the given attribute field - */ - private Comparator comparisonForField(String field) { - // Determine if ascending or descending - if (field.charAt(0) == DESCENDING_TOKEN) { - // Remove descending token to get field name - String parsedField = field.substring(1); - return Ordering.from(attributeComparison(parsedField)).nullsFirst().reverse(); - } else { - return Ordering.from(attributeComparison(field)).nullsFirst(); - } - } - - /** - * Attribute comparator. - * - * @param field - name of attribute field to compare - * @return a comparison between resources for the given attribute field - */ - private Comparator attributeComparison(String field) { - return (a, b) -> - // Compare requested attribute using an object comparison - Ordering.from(this::compareObjects) - .nullsFirst() - .compare( - a.getAttributes().get(field), - b.getAttributes().get(field) - ); - } - - /** - * Compare objects. - * - * @param a first object to compare - * @param b second object to compare - * @return negative number if a < b - * positive number if b > a - * 0 if a == b - * 0 if a comparison for the given object class cannot be made - */ - private int compareObjects(Object a, Object b) { - if (a instanceof Comparable) { - return ((Comparable) a).compareTo(b); - } - - // Can't compare objects without Comparable implementation - return 0; - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/extensions/JsonApiPatch.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java similarity index 79% rename from elide-core/src/main/java/com/yahoo/elide/extensions/JsonApiPatch.java rename to elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java index 24879e1a25..0379847a43 100644 --- a/elide-core/src/main/java/com/yahoo/elide/extensions/JsonApiPatch.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java @@ -1,32 +1,34 @@ /* - * Copyright 2015, Yahoo Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.extensions; +package com.yahoo.elide.jsonapi.extensions; -import com.yahoo.elide.Elide; -import com.yahoo.elide.core.DataStore; -import com.yahoo.elide.core.HttpStatus; import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.core.exceptions.JsonPatchExtensionException; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.exceptions.HttpStatus; import com.yahoo.elide.core.exceptions.HttpStatusException; import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; +import com.yahoo.elide.core.exceptions.JsonPatchExtensionException; import com.yahoo.elide.jsonapi.models.Data; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.jsonapi.models.Patch; import com.yahoo.elide.jsonapi.models.Resource; -import com.yahoo.elide.parsers.DeleteVisitor; -import com.yahoo.elide.parsers.PatchVisitor; -import com.yahoo.elide.parsers.PostVisitor; - +import com.yahoo.elide.jsonapi.parser.DeleteVisitor; +import com.yahoo.elide.jsonapi.parser.JsonApiParser; +import com.yahoo.elide.jsonapi.parser.PatchVisitor; +import com.yahoo.elide.jsonapi.parser.PostVisitor; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.collections4.IterableUtils; import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; +import org.owasp.encoder.Encode; import java.io.IOException; import java.util.Arrays; @@ -62,7 +64,7 @@ public void postProcess(PatchRequestScope requestScope) { // Only update relationships clearAllExceptRelationships(doc); PatchVisitor visitor = new PatchVisitor(new PatchRequestScope(path, doc, requestScope)); - visitor.visit(Elide.parse(path)); + visitor.visit(JsonApiParser.parse(path)); } catch (HttpStatusException e) { cause = e; throw e; @@ -95,14 +97,17 @@ public void postProcess(PatchRequestScope requestScope) { * @param patchDoc the patch doc * @param requestScope request scope * @return pair - * @throws IOException the iO exception */ public static Supplier> processJsonPatch(DataStore dataStore, String uri, String patchDoc, - PatchRequestScope requestScope) - throws IOException { - List actions = requestScope.getMapper().readJsonApiPatchExtDoc(patchDoc); + PatchRequestScope requestScope) { + List actions; + try { + actions = requestScope.getMapper().readJsonApiPatchExtDoc(patchDoc); + } catch (IOException e) { + throw new InvalidEntityBodyException(patchDoc); + } JsonApiPatch processor = new JsonApiPatch(dataStore, actions, uri, requestScope); return processor.processActions(requestScope); } @@ -133,20 +138,17 @@ private Supplier> processActions(PatchRequestScope reque postProcessRelationships(requestScope); - // Avoid any lazy loading issues - results.forEach(Supplier::get); - return () -> { try { return Pair.of(HttpStatus.SC_OK, mergeResponse(results)); } catch (HttpStatusException e) { - throwErrorResponse(e, requestScope.getPermissionExecutor().isVerbose()); + throwErrorResponse(); // NOTE: This should never be called. throwErrorResponse should _always_ throw an exception return null; } }; } catch (HttpStatusException e) { - throwErrorResponse(e, requestScope.getPermissionExecutor().isVerbose()); + throwErrorResponse(); // NOTE: This should never be called. throwErrorResponse should _always_ throw an exception return () -> null; } @@ -162,6 +164,11 @@ private List>> handleActions(PatchRequestScope return actions.stream().map(action -> { Supplier> result; try { + String path = action.patch.getPath(); + if (path == null) { + throw new InvalidEntityBodyException("Patch extension requires all objects " + + "to have an assigned path"); + } String[] combined = ArrayUtils.addAll(rootUri.split("/"), action.patch.getPath().split("/")); String fullPath = String.join("/", combined).replace("/-", ""); switch (action.patch.getOperation()) { @@ -169,7 +176,7 @@ private List>> handleActions(PatchRequestScope result = handleAddOp(fullPath, action.patch.getValue(), requestScope, action); break; case REPLACE: - result = handleReplaceOp(fullPath, action.patch.getValue(), requestScope); + result = handleReplaceOp(fullPath, action.patch.getValue(), requestScope, action); break; case REMOVE: result = handleRemoveOp(fullPath, action.patch.getValue(), requestScope); @@ -197,9 +204,15 @@ private Supplier> handleAddOp( if (data == null || data.get() == null) { throw new InvalidEntityBodyException("Expected an entity body but received none."); } + Collection resources = data.get(); if (!path.contains("relationships")) { // Reserved key for relationships String id = getSingleResource(resources).getId(); + + if (StringUtils.isEmpty(id)) { + throw new InvalidEntityBodyException("Patch extension requires all objects to have an assigned " + + "ID (temporary or permanent) when assigning relationships."); + } String fullPath = path + "/" + id; // Defer relationship updating until the end getSingleResource(resources).setRelationships(null); @@ -209,7 +222,7 @@ private Supplier> handleAddOp( action.isPostProcessing = true; } PostVisitor visitor = new PostVisitor(new PatchRequestScope(path, value, requestScope)); - return visitor.visit(Elide.parse(path)); + return visitor.visit(JsonApiParser.parse(path)); } catch (HttpStatusException e) { action.cause = e; throw e; @@ -222,12 +235,23 @@ private Supplier> handleAddOp( * Replace data via patch extension. */ private Supplier> handleReplaceOp( - String path, JsonNode patchVal, PatchRequestScope requestScope) { + String path, JsonNode patchVal, PatchRequestScope requestScope, PatchAction action) { try { JsonApiDocument value = requestScope.getMapper().readJsonApiPatchExtValue(patchVal); + + if (!path.contains("relationships")) { // Reserved + Data data = value.getData(); + Collection resources = data.get(); + // Defer relationship updating until the end + getSingleResource(resources).setRelationships(null); + // Reparse since we mangle it first + action.doc = requestScope.getMapper().readJsonApiPatchExtValue(patchVal); + action.path = path; + action.isPostProcessing = true; + } // Defer relationship updating until the end PatchVisitor visitor = new PatchVisitor(new PatchRequestScope(path, value, requestScope)); - return visitor.visit(Elide.parse(path)); + return visitor.visit(JsonApiParser.parse(path)); } catch (IOException e) { throw new InvalidEntityBodyException("Could not parse patch extension value: " + patchVal); } @@ -256,7 +280,7 @@ private Supplier> handleRemoveOp(String path, } DeleteVisitor visitor = new DeleteVisitor( new PatchRequestScope(path, value, requestScope)); - return visitor.visit(Elide.parse(fullPath)); + return visitor.visit(JsonApiParser.parse(fullPath)); } catch (IOException e) { throw new InvalidEntityBodyException("Could not parse patch extension value: " + patchValue); } @@ -279,38 +303,47 @@ private void postProcessRelationships(PatchRequestScope requestScope) { /** * Turn an exception into a proper error response from patch extension. */ - private void throwErrorResponse(HttpStatusException e, boolean isVerbose) { - if (e.getStatus() == HttpStatus.SC_FORBIDDEN) { - throw new JsonPatchExtensionException(isVerbose ? e.getVerboseErrorResponse() : e.getErrorResponse()); + private void throwErrorResponse() { + ArrayNode errorContainer = getErrorContainer(); + + boolean failed = false; + for (PatchAction action : actions) { + failed = processAction(errorContainer, failed, action); } - ObjectNode errorContainer = getErrorContainer(); - ArrayNode errorList = (ArrayNode) errorContainer.get("errors"); + JsonPatchExtensionException failure = + new JsonPatchExtensionException(HttpStatus.SC_BAD_REQUEST, errorContainer); - boolean failed = false; + // attach error causes to exception for (PatchAction action : actions) { - failed = processAction(errorList, failed, action); + if (action.cause != null) { + failure.addSuppressed(action.cause); + } } - throw new JsonPatchExtensionException(HttpStatus.SC_BAD_REQUEST, errorContainer); + + throw failure; } - private ObjectNode getErrorContainer() { - ObjectNode container = JsonNodeFactory.instance.objectNode(); - container.set("errors", JsonNodeFactory.instance.arrayNode()); - return container; + private ArrayNode getErrorContainer() { + return JsonNodeFactory.instance.arrayNode(); } 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; } @@ -341,9 +374,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; } @@ -375,7 +408,7 @@ public static boolean isPatchExtension(String header) { } // Find ext=jsonpatch - return Arrays.asList(header.split(";")).stream() + return Arrays.stream(header.split(";")) .map(key -> key.split("=")) .filter(value -> value.length == 2) .anyMatch(value -> value[0].trim().equals("ext") && value[1].trim().equals("jsonpatch")); @@ -385,6 +418,6 @@ private static Resource getSingleResource(Collection resources) { if (resources == null || resources.size() != 1) { throw new InvalidEntityBodyException("Expected single resource."); } - return resources.iterator().next(); + return IterableUtils.first(resources); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/PatchRequestScope.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/PatchRequestScope.java new file mode 100644 index 0000000000..3310757f72 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/PatchRequestScope.java @@ -0,0 +1,73 @@ +/* + * Copyright 2017, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.extensions; + +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.jsonapi.EntityProjectionMaker; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Special request scope for Patch Extension. + */ +public class PatchRequestScope extends RequestScope { + + /** + * Outer RequestScope constructor for use by Patch Extension. + * + * @param baseUrlEndPoint base URL with prefix endpoint + * @param path the URL path + * @param apiVersion client requested API version + * @param transaction current database transaction + * @param user request user + * @param requestId request ID + * @param queryParams request query parameters + * @param requestHeaders request headers + * @param elideSettings Elide settings object + */ + public PatchRequestScope( + String baseUrlEndPoint, + String path, + String apiVersion, + DataStoreTransaction transaction, + User user, + UUID requestId, + MultivaluedMap queryParams, + Map> requestHeaders, + ElideSettings elideSettings) { + super( + baseUrlEndPoint, + path, + apiVersion, + (JsonApiDocument) null, + transaction, + user, + queryParams, + requestHeaders, + requestId, + elideSettings + ); + } + + /** + * Inner RequestScope copy constructor for use by Patch Extension actions. + * + * @param path the URL path + * @param jsonApiDocument document + * @param scope outer request scope + */ + public PatchRequestScope(String path, JsonApiDocument jsonApiDocument, PatchRequestScope 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/links/DefaultJSONApiLinks.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/links/DefaultJSONApiLinks.java new file mode 100644 index 0000000000..647214314e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/links/DefaultJSONApiLinks.java @@ -0,0 +1,97 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.links; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.ResourceLineage; +import com.google.common.collect.ImmutableMap; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.Map; + +/*** + * Default API links populate provide 'self' and 'related' links. + * To add custom links, Override `getResourceLevelLinks` and `getRelationshipLinks`. + * Ad the subclass object in ElideAutoConfiguration. + */ +public class DefaultJSONApiLinks implements JSONApiLinks { + + private final String baseUrl; + + public DefaultJSONApiLinks() { + this(""); + } + + public DefaultJSONApiLinks(String baseUrl) { + this.baseUrl = baseUrl; + } + + @Override + public Map getResourceLevelLinks(PersistentResource resource) { + return ImmutableMap.of("self", getResourceUrl(resource)); + } + + @Override + public Map getRelationshipLinks(PersistentResource resource, String field) { + String resourceUrl = getResourceUrl(resource); + return ImmutableMap.of( + "self", String.join("/", resourceUrl, "relationships", field), + "related", String.join("/", resourceUrl, field)); + } + + /** + * Creates the link from resources path. + * @param resource + * @return + */ + protected String getResourceUrl(PersistentResource resource) { + StringBuilder result = new StringBuilder(); + + if (StringUtils.isEmpty(baseUrl)) { + if (resource.getRequestScope().getBaseUrlEndPoint() != null) { + result.append(resource.getRequestScope().getBaseUrlEndPoint()); + String jsonApiPath = resource.getRequestScope().getElideSettings().getJsonApiPath(); + if (StringUtils.isNotEmpty(jsonApiPath)) { + result.append(jsonApiPath); + } + result.append("/"); + } + } else { + result.append(baseUrl); + } + + List path = resource.getLineage().getResourcePath(); + if (CollectionUtils.isNotEmpty(path)) { + result.append(String.join("/", getPathSegment(path), resource.getId())); + } else { + result.append(String.join("/", resource.getTypeName(), resource.getId())); + } + + return result.toString(); + } + + private String getPathSegment(List path) { + StringBuilder result = new StringBuilder(); + + int pathSegmentCount = 0; + for (ResourceLineage.LineagePath pathElement : path) { + PersistentResource resource = pathElement.getResource(); + if (pathSegmentCount > 0) { + result.append("/"); + result.append(String.join("/", + resource.getId(), pathElement.getRelationship())); + } else { + result.append(String.join("/", + resource.getTypeName(), resource.getId(), pathElement.getRelationship())); + } + pathSegmentCount++; + } + + return result.toString(); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/links/JSONApiLinks.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/links/JSONApiLinks.java new file mode 100644 index 0000000000..b3c0f2c7e1 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/links/JSONApiLinks.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.jsonapi.links; + +import com.yahoo.elide.core.PersistentResource; + +import java.util.Map; + +public interface JSONApiLinks { + /** + * Links to be used in the Respose Entity. + * @param resource + * @return + */ + Map getResourceLevelLinks(PersistentResource resource); + + /** + * Links to be used in Relationships of the Response Entity. + * @param resource + * @return + */ + Map getRelationshipLinks(PersistentResource resource, String field); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Data.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Data.java index 866b8ba72f..9ab6213aa6 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Data.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Data.java @@ -5,20 +5,16 @@ */ package com.yahoo.elide.jsonapi.models; -import com.yahoo.elide.core.RelationshipType; +import com.yahoo.elide.core.dictionary.RelationshipType; import com.yahoo.elide.jsonapi.serialization.DataDeserializer; import com.yahoo.elide.jsonapi.serialization.DataSerializer; - import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; - +import io.reactivex.Observable; import lombok.ToString; -import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; -import java.util.List; -import java.util.stream.Collectors; /** * Container for different representations of top-level data in JSON API. @@ -29,7 +25,7 @@ @JsonDeserialize(using = DataDeserializer.class) @ToString public class Data { - private final Collection values; + private final Observable values; private final RelationshipType relationshipType; /** @@ -38,7 +34,11 @@ public class Data { * @param value singleton resource */ public Data(T value) { - this.values = new SingleElementSet<>(value); + if (value == null) { + this.values = Observable.empty(); + } else { + this.values = Observable.fromArray(value); + } this.relationshipType = RelationshipType.MANY_TO_ONE; // Any "toOne" } @@ -47,9 +47,39 @@ public Data(T value) { * * @param values List of resources */ - public Data(Collection values) { + public Data(Observable values) { + this(values, RelationshipType.MANY_TO_MANY); + } + + /** + * Constructor. + * + * @param values List of resources + * @param relationshipType toOne or toMany + */ + public Data(Observable values, RelationshipType relationshipType) { this.values = values; - this.relationshipType = RelationshipType.MANY_TO_MANY; // Any "toMany" + this.relationshipType = relationshipType; + } + + /** + * Constructor. + * + * @param values List of resources + */ + public Data(Collection values) { + this(values, RelationshipType.MANY_TO_MANY); + } + + /** + * Constructor. + * + * @param values List of resources + * @param relationshipType toOne or toMany + */ + public Data(Collection values, RelationshipType relationshipType) { + this.values = Observable.fromIterable(values); + this.relationshipType = relationshipType; } /** @@ -58,18 +88,11 @@ public Data(Collection values) { * @param sortFunction comparator to sort data with */ public void sort(Comparator sortFunction) { - if (values instanceof List) { - ((List) values).sort(sortFunction); - } else { - ArrayList sortedList = new ArrayList<>(values); - sortedList.sort(sortFunction); - values.clear(); - values.addAll(sortedList); - } + this.values.sorted(sortFunction); } public Collection get() { - return values; + return values.toList().blockingGet(); } /** @@ -82,23 +105,25 @@ public boolean isToOne() { } /** - * Fetch the item if the data is toOne + * Fetch the item if the data is toOne. * * @return T if toOne * @throws IllegalAccessError when the data is not toOne */ public T getSingleValue() { if (isToOne()) { - return ((SingleElementSet) values).getValue(); + if (values.isEmpty().blockingGet()) { + return null; + } + return values.blockingSingle(); } throw new IllegalAccessError("Data is not toOne"); } - @SuppressWarnings("unchecked") public Collection toResourceIdentifiers() { - return ((Collection) get()).stream() - .map(object -> object != null ? object.toResourceIdentifier() : null) - .collect(Collectors.toList()); + return values + .map(object -> object != null ? ((Resource) object).toResourceIdentifier() : null) + .toList().blockingGet(); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/JsonApiDocument.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/JsonApiDocument.java index 9755a9776c..47c3b8ddcf 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/JsonApiDocument.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/JsonApiDocument.java @@ -7,15 +7,17 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; -import lombok.ToString; import org.apache.commons.lang3.builder.HashCodeBuilder; +import lombok.ToString; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; /** * JSON API Document. @@ -41,13 +43,9 @@ public JsonApiDocument(Data data) { public void setData(Data data) { this.data = data; - this.meta = null; } public Data getData() { - if (data == null) { - return null; - } return data; } @@ -83,12 +81,11 @@ public void addIncluded(Resource resource) { @Override public int hashCode() { + Collection resources = data == null ? null : data.get(); return new HashCodeBuilder(37, 79) - .append(data) - .append(meta) - .append(includedRecs) + .append(resources == null ? 0 : resources.stream().mapToInt(Object::hashCode).sum()) .append(links) - .append(included) + .append(included == null ? 0 : included.stream().mapToInt(Object::hashCode).sum()) .build(); } @@ -98,14 +95,12 @@ public boolean equals(Object obj) { return false; } JsonApiDocument other = (JsonApiDocument) obj; - Collection resources = data.get(); - if ((resources == null || other.getData().get() == null) && resources != other.getData().get()) { + Collection resources = Optional.ofNullable(data).map(Data::get).orElseGet(Collections::emptySet); + Collection otherResources = + Optional.ofNullable(other.data).map(Data::get).orElseGet(Collections::emptySet); + + if (resources.size() != otherResources.size() || !resources.stream().allMatch(otherResources::contains)) { return false; - } else if (resources != null) { - if (resources.size() != other.getData().get().size() - || !resources.stream().allMatch(other.getData().get()::contains)) { - return false; - } } // TODO: Verify links and meta? if (other.getIncluded() == null) { diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Meta.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Meta.java index 34b57b4f6a..ba26533aad 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Meta.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Meta.java @@ -5,8 +5,10 @@ */ package com.yahoo.elide.jsonapi.models; +import com.yahoo.elide.jsonapi.serialization.MetaDeserializer; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import java.util.Map; @@ -14,6 +16,7 @@ * Model for representing JSON API meta information. */ @JsonAutoDetect +@JsonDeserialize(using = MetaDeserializer.class) public class Meta extends KeyValMap { /** @@ -26,7 +29,7 @@ public Meta(Map map) { } /** - * Expose the meta map so that it will be included in the returned JSON-API document + * Expose the meta map so that it will be included in the returned JSON-API document. * * @return the meta map */ diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Relationship.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Relationship.java index 764edc3f8e..158b100711 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Relationship.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Relationship.java @@ -9,14 +9,15 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException; - import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.commons.collections4.MapUtils; import java.util.Collection; import java.util.LinkedHashSet; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -40,6 +41,7 @@ public Relationship(@JsonProperty("links") Map links, } else { this.idData = new Data<>( data.get().stream() + .map(Objects::requireNonNull) .map(Resource::toResourceIdentifier) .collect(Collectors.toList()) ); @@ -51,7 +53,7 @@ public Relationship(@JsonProperty("links") Map links, @JsonInclude(JsonInclude.Include.NON_NULL) public Map getLinks() { - return links == null || links.isEmpty() ? null : links; + return MapUtils.isEmpty(links) ? null : links; } @JsonInclude(JsonInclude.Include.NON_NULL) @@ -74,7 +76,7 @@ public Set toPersistentResources(RequestScope requestScope) if (resources != null) { for (Resource resource : resources) { try { - if (data.isToOne() && resource == null) { + if (resource == null) { continue; } res.add(resource.toPersistentResource(requestScope)); 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 6284583923..14601068dd 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,18 +5,21 @@ */ package com.yahoo.elide.jsonapi.models; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; import com.yahoo.elide.core.exceptions.UnknownEntityException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.type.Type; import com.yahoo.elide.jsonapi.serialization.KeySerializer; -import lombok.ToString; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.builder.HashCodeBuilder; +import lombok.ToString; import java.util.Map; import java.util.Objects; @@ -30,6 +33,9 @@ */ @ToString public class Resource { + + //Doesn't work currently - https://github.com/FasterXML/jackson-databind/issues/230 + @JsonProperty(required = true) private String type; private String id; private Map attributes; @@ -40,6 +46,9 @@ public class Resource { public Resource(String type, String id) { this.type = type; this.id = id; + if (id == null) { + throw new InvalidObjectIdentifierException(id, type); + } } public Resource(@JsonProperty("type") String type, @@ -57,7 +66,7 @@ public Resource(@JsonProperty("type") String type, } public String getId() { - return (id == null) ? null : id; + return id; } public void setRelationships(Map relationships) { @@ -66,13 +75,13 @@ public void setRelationships(Map relationships) { @JsonInclude(JsonInclude.Include.NON_NULL) public Map getRelationships() { - return relationships == null || relationships.isEmpty() ? null : relationships; + return MapUtils.isEmpty(relationships) ? null : relationships; } @JsonInclude(JsonInclude.Include.NON_NULL) @JsonSerialize(keyUsing = KeySerializer.class) public Map getAttributes() { - return attributes == null || attributes.isEmpty() ? null : attributes; + return MapUtils.isEmpty(attributes) ? null : attributes; } public void setId(String id) { @@ -139,12 +148,23 @@ public boolean equals(Object obj) { return false; } - public PersistentResource toPersistentResource(RequestScope requestScope) + public PersistentResource toPersistentResource(RequestScope requestScope) throws ForbiddenAccessException, InvalidObjectIdentifierException { - Class cls = requestScope.getDictionary().getEntityClass(type); + EntityDictionary dictionary = requestScope.getDictionary(); + + Type cls = dictionary.getEntityClass(type, requestScope.getApiVersion()); + if (cls == null) { throw new UnknownEntityException(type); } - return PersistentResource.loadRecord(cls, id, requestScope); + if (id == null) { + throw new InvalidObjectIdentifierException(id, type); + } + + 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 f99c58ccc3..658ec64dde 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 @@ -5,14 +5,13 @@ */ package com.yahoo.elide.jsonapi.models; - +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.PersistentResource; - +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.type.Type; import com.fasterxml.jackson.annotation.JsonProperty; -import com.yahoo.elide.core.RequestScope; - import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.HashCodeBuilder; @@ -38,8 +37,11 @@ public String getId() { public PersistentResource toPersistentResource(RequestScope requestScope) throws ForbiddenAccessException, InvalidObjectIdentifierException { - Class cls = requestScope.getDictionary().getEntityClass(type); - return PersistentResource.loadRecord(cls, id, requestScope); + + Type 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/jsonapi/models/SingleElementSet.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/SingleElementSet.java deleted file mode 100644 index 0f1a99249b..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/SingleElementSet.java +++ /dev/null @@ -1,45 +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.jsonapi.models; - -import com.yahoo.elide.jsonapi.serialization.SingletonSerializer; - -import com.fasterxml.jackson.databind.annotation.JsonSerialize; - -import java.util.AbstractSet; -import java.util.Collections; -import java.util.Iterator; - -/** - * Single object treated as a Set. - * - * @param the type of element to treat as a set - */ -@JsonSerialize(using = SingletonSerializer.class) -public class SingleElementSet extends AbstractSet { - - private final T value; - - public SingleElementSet(T v) { - value = v; - } - - public T getValue() { - return value; - } - - @Override - public int size() { - return value == null ? 0 : 1; - } - - @Override - public Iterator iterator() { - return value == null - ? Collections.emptyIterator() - : Collections.singleton(value).iterator(); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/BaseVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/BaseVisitor.java similarity index 96% rename from elide-core/src/main/java/com/yahoo/elide/parsers/BaseVisitor.java rename to elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/BaseVisitor.java index 02edd9c1cc..40a091885a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/BaseVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/BaseVisitor.java @@ -3,9 +3,8 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.parsers; +package com.yahoo.elide.jsonapi.parser; -import com.fasterxml.jackson.databind.JsonNode; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.generated.parsers.CoreBaseVisitor; import com.yahoo.elide.generated.parsers.CoreParser.EntityContext; @@ -21,8 +20,9 @@ import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionRelationshipContext; import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionSubCollectionContext; import com.yahoo.elide.generated.parsers.CoreParser.TermContext; -import com.yahoo.elide.parsers.state.StartState; -import com.yahoo.elide.parsers.state.StateContext; +import com.yahoo.elide.jsonapi.parser.state.StartState; +import com.yahoo.elide.jsonapi.parser.state.StateContext; +import com.fasterxml.jackson.databind.JsonNode; import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/DeleteVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/DeleteVisitor.java similarity index 95% rename from elide-core/src/main/java/com/yahoo/elide/parsers/DeleteVisitor.java rename to elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/DeleteVisitor.java index df2415b59c..681e8565ef 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/DeleteVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/DeleteVisitor.java @@ -3,13 +3,11 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.parsers; +package com.yahoo.elide.jsonapi.parser; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.generated.parsers.CoreParser.QueryContext; - import com.fasterxml.jackson.databind.JsonNode; - import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/GetVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/GetVisitor.java similarity index 94% rename from elide-core/src/main/java/com/yahoo/elide/parsers/GetVisitor.java rename to elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/GetVisitor.java index 17a8b6ddd8..f5bb77f122 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/GetVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/GetVisitor.java @@ -3,14 +3,11 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.parsers; - +package com.yahoo.elide.jsonapi.parser; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.generated.parsers.CoreParser.QueryContext; - import com.fasterxml.jackson.databind.JsonNode; - import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/JsonApiParser.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/JsonApiParser.java new file mode 100644 index 0000000000..3084a8fb3e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/JsonApiParser.java @@ -0,0 +1,67 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.parser; + +import com.yahoo.elide.generated.parsers.CoreLexer; +import com.yahoo.elide.generated.parsers.CoreParser; +import org.antlr.v4.runtime.BailErrorStrategy; +import org.antlr.v4.runtime.BaseErrorListener; +import org.antlr.v4.runtime.CharStream; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.RecognitionException; +import org.antlr.v4.runtime.Recognizer; +import org.antlr.v4.runtime.misc.ParseCancellationException; +import org.antlr.v4.runtime.tree.ParseTree; +import org.apache.commons.lang3.StringUtils; + +import java.util.regex.Pattern; + +/** + * Parses the REST request. + */ +public class JsonApiParser { + + private static final Pattern DUPLICATE_SEPARATOR_PATTERN = Pattern.compile("//+"); + + /** + * Normalize request path + * + * @param path request path + * @return normalized path string + */ + public static String normalizePath(String path) { + String normalizedPath = DUPLICATE_SEPARATOR_PATTERN.matcher(path).replaceAll("/"); + + normalizedPath = StringUtils.removeEnd(normalizedPath, "/"); + + return StringUtils.removeStart(normalizedPath, "/"); + } + + /** + * Compile request to AST. + * + * @param path request + * @return AST parse tree + */ + public static ParseTree parse(String path) { + String normalizedPath = normalizePath(path); + + CharStream is = CharStreams.fromString(normalizedPath); + CoreLexer lexer = new CoreLexer(is); + lexer.removeErrorListeners(); + lexer.addErrorListener(new BaseErrorListener() { + @Override + public void syntaxError(Recognizer recognizer, Object offendingSymbol, int line, + int charPositionInLine, String msg, RecognitionException e) { + throw new ParseCancellationException(msg, e); + } + }); + CoreParser parser = new CoreParser(new CommonTokenStream(lexer)); + parser.setErrorHandler(new BailErrorStrategy()); + return parser.start(); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/PatchVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PatchVisitor.java similarity index 94% rename from elide-core/src/main/java/com/yahoo/elide/parsers/PatchVisitor.java rename to elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PatchVisitor.java index 513184e5e4..7b79df4b15 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/PatchVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PatchVisitor.java @@ -3,13 +3,11 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.parsers; +package com.yahoo.elide.jsonapi.parser; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.generated.parsers.CoreParser.QueryContext; - import com.fasterxml.jackson.databind.JsonNode; - import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/PostVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PostVisitor.java similarity index 94% rename from elide-core/src/main/java/com/yahoo/elide/parsers/PostVisitor.java rename to elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PostVisitor.java index cd3127715a..d909199192 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/PostVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PostVisitor.java @@ -3,13 +3,11 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.parsers; +package com.yahoo.elide.jsonapi.parser; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.generated.parsers.CoreParser.QueryContext; - import com.fasterxml.jackson.databind.JsonNode; - import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/BaseState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/BaseState.java new file mode 100644 index 0000000000..1ff16b504f --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/BaseState.java @@ -0,0 +1,194 @@ +/* + * Copyright 2015, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.parser.state; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.core.exceptions.HttpStatusException; +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.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.document.processors.DocumentProcessor; +import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; +import com.yahoo.elide.jsonapi.models.Data; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Resource; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.lang3.tuple.Pair; + +import java.util.function.Supplier; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Base class for state information. + */ +public abstract class BaseState { + + /** + * Handle void. + * + * @param state the state + * @param ctx the ctx + */ + public void handle (StateContext state, RootCollectionLoadEntitiesContext ctx) { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Handle void. + * + * @param state the state + * @param ctx the ctx + */ + public void handle (StateContext state, RootCollectionLoadEntityContext ctx) { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Handle void. + * + * @param state the state + * @param ctx the ctx + */ + public void handle (StateContext state, RootCollectionSubCollectionContext ctx) { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Handle void. + * + * @param state the state + * @param ctx the ctx + */ + public void handle(StateContext state, RootCollectionRelationshipContext ctx) { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Handle void. + * + * @param state the state + * @param ctx the ctx + */ + public void handle (StateContext state, SubCollectionReadCollectionContext ctx) { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Handle void. + * + * @param state the state + * @param ctx the ctx + */ + public void handle (StateContext state, SubCollectionReadEntityContext ctx) { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Handle void. + * + * @param state the state + * @param ctx the ctx + */ + public void handle (StateContext state, SubCollectionSubCollectionContext ctx) { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + + /** + * Handle void. + * + * @param state the state + * @param ctx the ctx + */ + public void handle(StateContext state, SubCollectionRelationshipContext ctx) { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * We return a Function because we may have to perform post-commit operations. That is, + * we may need to perform extra operations after having closed a transaction. As a result, + * this method is invoked after committing a transaction in Elide.java. + * @param state the state + * @return the supplier + * @throws HttpStatusException the http status exception + */ + public Supplier> handleGet(StateContext state) throws HttpStatusException { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Handle patch. + * + * @param state the state + * @return the supplier + * @throws HttpStatusException the http status exception + */ + public Supplier> handlePatch(StateContext state) throws HttpStatusException { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Handle post. + * + * @param state the state + * @return the supplier + * @throws HttpStatusException the http status exception + */ + public Supplier> handlePost(StateContext state) throws HttpStatusException { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Handle delete. + * + * @param state the state + * @return the supplier + * @throws HttpStatusException the http status exception + */ + public Supplier> handleDelete(StateContext state) throws HttpStatusException { + throw new UnsupportedOperationException(this.getClass().toString()); + } + + /** + * Construct PATCH response. + * + * @param record a resource that has been updated + * @param stateContext a state that contains reference to request scope where we can get status code for update + * @return a supplier of PATH response + */ + protected static Supplier> constructPatchResponse( + PersistentResource record, + StateContext stateContext) { + RequestScope requestScope = stateContext.getRequestScope(); + int updateStatusCode = requestScope.getUpdateStatusCode(); + return () -> Pair.of( + updateStatusCode, + updateStatusCode == HttpStatus.SC_NO_CONTENT ? null : getResponseBody(record, requestScope) + ); + } + + protected static JsonNode getResponseBody(PersistentResource resource, RequestScope requestScope) { + MultivaluedMap queryParams = requestScope.getQueryParams(); + JsonApiDocument jsonApiDocument = new JsonApiDocument(); + + //TODO Make this a document processor + Data data = resource == null ? null : new Data<>(resource.toResource()); + jsonApiDocument.setData(data); + + //TODO Iterate over set of document processors + DocumentProcessor includedProcessor = new IncludedProcessor(); + includedProcessor.execute(jsonApiDocument, resource, queryParams); + + return requestScope.getMapper().toJsonObject(jsonApiDocument); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java new file mode 100644 index 0000000000..060c859d2f --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java @@ -0,0 +1,229 @@ +/* + * Copyright 2015, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.parser.state; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.RelationshipType; +import com.yahoo.elide.core.exceptions.ForbiddenAccessException; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.core.exceptions.InternalServerErrorException; +import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; +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.request.EntityProjection; +import com.yahoo.elide.core.request.Pagination; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.jsonapi.document.processors.DocumentProcessor; +import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; +import com.yahoo.elide.jsonapi.models.Data; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Meta; +import com.yahoo.elide.jsonapi.models.Relationship; +import com.yahoo.elide.jsonapi.models.Resource; +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; +import io.reactivex.Observable; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Collection State. + */ +@ToString +public class CollectionTerminalState extends BaseState { + private final Optional parent; + private final Optional relationName; + private final Type entityClass; + private PersistentResource newObject; + private final EntityProjection parentProjection; + + public CollectionTerminalState(Type entityClass, Optional parent, + Optional relationName, EntityProjection projection) { + this.parentProjection = projection; + this.parent = parent; + this.relationName = relationName; + this.entityClass = entityClass; + } + + @Override + public Supplier> handleGet(StateContext state) { + JsonApiDocument jsonApiDocument = new JsonApiDocument(); + RequestScope requestScope = state.getRequestScope(); + MultivaluedMap queryParams = requestScope.getQueryParams(); + + Set collection = + getResourceCollection(requestScope).toList(LinkedHashSet::new).blockingGet(); + + // Set data + jsonApiDocument.setData(getData(collection, requestScope.getDictionary())); + + // Run include processor + DocumentProcessor includedProcessor = new IncludedProcessor(); + includedProcessor.execute(jsonApiDocument, collection, queryParams); + + Pagination pagination = parentProjection.getPagination(); + if (parent.isPresent()) { + pagination = parentProjection.getRelationship(relationName.orElseThrow(IllegalStateException::new)) + .get().getProjection().getPagination(); + } + + // Add pagination meta data + 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.returnPageTotals()) { + Long totalRecords = pagination.getPageTotals(); + pageMetaData.put("totalPages", totalRecords / pagination.getLimit() + + ((totalRecords % pagination.getLimit()) > 0 ? 1 : 0)); + pageMetaData.put("totalRecords", totalRecords); + } + + Map allMetaData = new HashMap<>(); + allMetaData.put("page", pageMetaData); + + Meta meta = new Meta(allMetaData); + jsonApiDocument.setMeta(meta); + } + + JsonNode responseBody = requestScope.getMapper().toJsonObject(jsonApiDocument); + + return () -> Pair.of(HttpStatus.SC_OK, responseBody); + } + + @Override + public Supplier> handlePost(StateContext state) { + RequestScope requestScope = state.getRequestScope(); + JsonApiMapper mapper = requestScope.getMapper(); + + newObject = createObject(requestScope); + parent.ifPresent(persistentResource -> persistentResource.addRelation(relationName.get(), newObject)); + return () -> { + JsonApiDocument returnDoc = new JsonApiDocument(); + returnDoc.setData(new Data<>(newObject.toResource())); + JsonNode responseBody = mapper.getObjectMapper().convertValue(returnDoc, JsonNode.class); + return Pair.of(HttpStatus.SC_CREATED, responseBody); + }; + } + + private Observable getResourceCollection(RequestScope requestScope) { + final Observable collection; + // TODO: In case of join filters, apply pagination after getting records + // instead of passing it to the datastore + + if (parent.isPresent()) { + collection = parent.get().getRelationCheckedFiltered( + parentProjection.getRelationship(relationName.orElseThrow(IllegalStateException::new)) + .orElseThrow(IllegalStateException::new)); + } else { + collection = PersistentResource.loadRecords( + parentProjection, + new ArrayList<>(), //Empty list of IDs + requestScope); + } + + return collection; + } + + private Data getData(Set collection, EntityDictionary dictionary) { + Preconditions.checkNotNull(collection); + List resources = collection.stream().map(PersistentResource::toResource).collect(Collectors.toList()); + + if (parent.isPresent()) { + Type parentClass = parent.get().getResourceType(); + String relationshipName = relationName.orElseThrow(IllegalStateException::new); + RelationshipType type = dictionary.getRelationshipType(parentClass, relationshipName); + + return new Data<>(resources, type); + } + return new Data<>(resources); + } + + private PersistentResource createObject(RequestScope requestScope) + throws ForbiddenAccessException, InvalidObjectIdentifierException { + JsonApiDocument doc = requestScope.getJsonApiDocument(); + JsonApiMapper mapper = requestScope.getMapper(); + + if (doc.getData() == null) { + throw new InvalidEntityBodyException("Invalid JSON-API document: " + doc); + } + + Data data = doc.getData(); + Collection resources = data.get(); + + Resource resource = (resources.size() == 1) ? IterableUtils.first(resources) : null; + if (resource == null) { + try { + throw new InvalidEntityBodyException(mapper.writeJsonApiDocument(doc)); + } catch (JsonProcessingException e) { + throw new InternalServerErrorException(e); + } + } + + String id = resource.getId(); + + Type newObjectClass = requestScope.getDictionary().getEntityClass(resource.getType(), + requestScope.getApiVersion()); + + if (newObjectClass == null) { + throw new UnknownEntityException("Entity " + resource.getType() + " not found"); + } + if (!entityClass.isAssignableFrom(newObjectClass)) { + throw new InvalidValueException("Cannot assign value of type: " + resource.getType() + + " to type: " + entityClass); + } + + PersistentResource pResource = PersistentResource.createObject( + parent.orElse(null), + relationName.orElse(null), + newObjectClass, + requestScope, Optional.ofNullable(id)); + + Map attributes = resource.getAttributes(); + if (attributes != null) { + for (Map.Entry entry : attributes.entrySet()) { + String fieldName = entry.getKey(); + Object val = entry.getValue(); + pResource.updateAttribute(fieldName, val); + } + } + + Map relationships = resource.getRelationships(); + if (relationships != null) { + for (Map.Entry entry : relationships.entrySet()) { + String fieldName = entry.getKey(); + Relationship relationship = entry.getValue(); + Set resourceSet = (relationship == null) + ? null + : relationship.toPersistentResources(requestScope); + pResource.updateRelation(fieldName, resourceSet); + } + } + + return pResource; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordState.java new file mode 100644 index 0000000000..3e7e6dc820 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordState.java @@ -0,0 +1,108 @@ +/* + * Copyright 2015, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.parser.state; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.RelationshipType; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.core.type.Type; +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.google.common.base.Preconditions; +import io.reactivex.Observable; + +import java.util.Optional; + +/** + * Record Read State. + */ +public class RecordState extends BaseState { + private final 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(); + + Type entityClass; + String entityName; + + RelationshipType type = dictionary.getRelationshipType(resource.getObject(), subCollection); + + Type paramType = dictionary.getParameterizedType(resource.getObject(), subCollection); + + 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); + Observable collection = null; + if (type.isToOne()) { + collection = resource.getRelationCheckedFiltered(projection.getRelationship(subCollection) + .orElseThrow(IllegalStateException::new)); + PersistentResource record = PersistentResource.firstOrNullIfEmpty(collection); + nextState = new RecordTerminalState(record, collectionTerminalState); + } else { + nextState = collectionTerminalState; + } + state.setState(nextState); + } + + @Override + public void handle(StateContext state, SubCollectionReadEntityContext ctx) { + String id = ctx.entity().id().getText(); + String subCollection = ctx.entity().term().getText(); + + 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(); + + 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; + + Relationship childRelationship = projection.getRelationship(subCollection) + .orElseThrow(IllegalStateException::new); + + childRecord = resource.getRelation(childRelationship , id); + + state.setState(new RelationshipTerminalState(childRecord, relationName, childRelationship.getProjection())); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordTerminalState.java new file mode 100644 index 0000000000..5bf89ad565 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordTerminalState.java @@ -0,0 +1,115 @@ +/* + * Copyright 2015, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.parser.state; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; +import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.jsonapi.models.Data; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Relationship; +import com.yahoo.elide.jsonapi.models.Resource; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.lang3.tuple.Pair; +import lombok.ToString; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Record Found State. + */ +@ToString +public class RecordTerminalState extends BaseState { + private final PersistentResource record; + private final Optional collectionTerminalState; + + public RecordTerminalState(PersistentResource record) { + this(record, null); + } + + // This constructor is to handle ToOne collection as a Record Terminal State + public RecordTerminalState(PersistentResource record, CollectionTerminalState collectionTerminalState) { + this.record = record; + this.collectionTerminalState = Optional.ofNullable(collectionTerminalState); + } + + @Override + public Supplier> handleGet(StateContext state) { + ObjectMapper mapper = state.getRequestScope().getMapper().getObjectMapper(); + return () -> Pair.of(HttpStatus.SC_OK, getResponseBody(record, state.getRequestScope())); + } + + @Override + public Supplier> handlePost(StateContext state) { + return collectionTerminalState + .orElseThrow(() -> new InvalidOperationException("Cannot POST to a record.")) + .handlePost(state); + } + + @Override + public Supplier> handlePatch(StateContext state) { + JsonApiDocument jsonApiDocument = state.getJsonApiDocument(); + + Data data = jsonApiDocument.getData(); + + if (data == null) { + throw new InvalidEntityBodyException("Expected data but found null"); + } + + if (!data.isToOne()) { + throw new InvalidEntityBodyException("Expected single element but found list"); + } + + Resource resource = data.getSingleValue(); + if (!record.matchesId(resource.getId())) { + throw new InvalidEntityBodyException("Id in response body does not match requested id to update from path"); + } + + patch(resource, state.getRequestScope()); + return constructPatchResponse(record, state); + } + + @Override + public Supplier> handleDelete(StateContext state) { + record.deleteResource(); + return () -> Pair.of(HttpStatus.SC_NO_CONTENT, null); + } + + private boolean patch(Resource resource, RequestScope requestScope) { + boolean isUpdated = false; + + // Update attributes first + Map attributes = resource.getAttributes(); + if (attributes != null) { + for (Map.Entry entry : attributes.entrySet()) { + String fieldName = entry.getKey(); + Object newVal = entry.getValue(); + isUpdated |= record.updateAttribute(fieldName, newVal); + } + } + + // Relations next + Map relationships = resource.getRelationships(); + if (relationships != null) { + for (Map.Entry entry : relationships.entrySet()) { + String fieldName = entry.getKey(); + Relationship relationship = entry.getValue(); + Set resources = (relationship == null) + ? null + : relationship.toPersistentResources(requestScope); + isUpdated |= record.updateRelation(fieldName, resources); + } + } + + return isUpdated; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RelationshipTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RelationshipTerminalState.java new file mode 100644 index 0000000000..c9108aec6a --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RelationshipTerminalState.java @@ -0,0 +1,180 @@ +/* + * Copyright 2015, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.parser.state; + +import com.yahoo.elide.annotation.UpdatePermission; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.RelationshipType; +import com.yahoo.elide.core.exceptions.ForbiddenAccessException; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.jsonapi.document.processors.DocumentProcessor; +import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; +import com.yahoo.elide.jsonapi.models.Data; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Relationship; +import com.yahoo.elide.jsonapi.models.Resource; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.tuple.Pair; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.function.BiPredicate; +import java.util.function.Supplier; +import javax.ws.rs.core.MultivaluedMap; + +/** + * State to handle relationships. + */ +public class RelationshipTerminalState extends BaseState { + private final PersistentResource record; + private final RelationshipType relationshipType; + private final 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; + } + + @Override + public Supplier> handleGet(StateContext state) { + JsonApiDocument doc = new JsonApiDocument(); + RequestScope requestScope = state.getRequestScope(); + JsonApiMapper mapper = requestScope.getMapper(); + MultivaluedMap queryParams = requestScope.getQueryParams(); + + Map relationships = record.toResource(parentProjection).getRelationships(); + if (relationships != null && relationships.containsKey(relationshipName)) { + Relationship relationship = relationships.get(relationshipName); + + // Handle valid relationship + + // Set data + Data data = relationship.getData(); + doc.setData(data); + + // Run include processor + DocumentProcessor includedProcessor = new IncludedProcessor(); + includedProcessor.execute(doc, record, queryParams); + + return () -> Pair.of(HttpStatus.SC_OK, mapper.toJsonObject(doc)); + } + + // Handle no data for relationship + if (relationshipType.isToMany()) { + doc.setData(new Data<>(new ArrayList<>())); + } else if (relationshipType.isToOne()) { + doc.setData(new Data<>((Resource) null)); + } else { + throw new IllegalStateException("Failed to GET a relationship; relationship is neither toMany nor toOne"); + } + return () -> Pair.of(HttpStatus.SC_OK, mapper.toJsonObject(doc)); + } + + @Override + public Supplier> handlePatch(StateContext state) { + return handleRequest(state, this::patch); + } + + @Override + public Supplier> handlePost(StateContext state) { + return handleRequest(state, this::post); + } + + @Override + public Supplier> handleDelete(StateContext state) { + return handleRequest(state, this::delete); + } + + /* + * Base on the JSON API docs relationship updates MUST return 204 unless the server has made additional modification + * to the relationship. http://jsonapi.org/format/#crud-updating-relationship-responses + */ + private Supplier> handleRequest(StateContext state, + BiPredicate, RequestScope> handler) { + Data data = state.getJsonApiDocument().getData(); + handler.test(data, state.getRequestScope()); + // TODO: figure out if we've made modifications that differ from those requested by client + return () -> Pair.of(HttpStatus.SC_NO_CONTENT, null); + } + + private boolean patch(Data data, RequestScope requestScope) { + boolean isUpdated; + + if (relationshipType.isToMany() && data == null) { + throw new InvalidEntityBodyException("Expected data but received null"); + } + + if (relationshipType.isToMany()) { + if (data == null) { + return false; + } + Collection resources = data.get(); + if (resources == null) { + return false; + } + if (!resources.isEmpty()) { + isUpdated = record.updateRelation(relationshipName, + new Relationship(null, new Data<>(resources)).toPersistentResources(requestScope)); + } else { + isUpdated = record.clearRelation(relationshipName); + } + } else if (relationshipType.isToOne()) { + if (data != null) { + Resource resource = data.getSingleValue(); + Relationship relationship = new Relationship(null, new Data<>(resource)); + isUpdated = record.updateRelation(relationshipName, relationship.toPersistentResources(requestScope)); + } else { + isUpdated = record.clearRelation(relationshipName); + } + } else { + throw new IllegalStateException("Bad relationship type"); + } + + return isUpdated; + } + + private boolean post(Data data, RequestScope requestScope) { + if (data == null) { + throw new InvalidEntityBodyException("Expected data but received null"); + } + + Collection resources = data.get(); + if (resources == null) { + return false; + } + resources.stream().forEachOrdered(resource -> + record.addRelation(relationshipName, resource.toPersistentResource(requestScope))); + return true; + } + + private boolean delete(Data data, RequestScope requestScope) { + if (data == null) { + throw new InvalidEntityBodyException("Expected data but received null"); + } + + Collection resources = data.get(); + if (CollectionUtils.isEmpty(resources)) { + // As per: http://jsonapi.org/format/#crud-updating-relationship-responses-403 + throw new ForbiddenAccessException(UpdatePermission.class); + } + resources.stream().forEachOrdered(resource -> + record.removeRelation(relationshipName, resource.toPersistentResource(requestScope))); + return true; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StartState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StartState.java new file mode 100644 index 0000000000..a710572058 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StartState.java @@ -0,0 +1,80 @@ +/* + * Copyright 2015, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.jsonapi.parser.state; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.InvalidCollectionException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.type.Type; +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 java.util.Optional; + +/** + * Initial State. + */ +public class StartState extends BaseState { + @Override + public void handle(StateContext state, RootCollectionLoadEntitiesContext ctx) { + String entityName = ctx.term().getText(); + EntityDictionary dictionary = state.getRequestScope().getDictionary(); + + Type entityClass = dictionary.getEntityClass(entityName, state.getRequestScope().getApiVersion()); + + state.setState(new CollectionTerminalState(entityClass, Optional.empty(), Optional.empty(), + state.getRequestScope().getEntityProjection())); + } + + @Override + public void handle(StateContext state, RootCollectionLoadEntityContext ctx) { + PersistentResource record = entityRecord(state, ctx.entity()); + state.setState(new RecordTerminalState(record)); + } + + @Override + public void handle(StateContext state, RootCollectionSubCollectionContext ctx) { + PersistentResource record = entityRecord(state, ctx.entity()); + + 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(); + + record.getRelationCheckedFiltered(projection.getRelationship(relationName) + .orElseThrow(IllegalStateException::new)); + + state.setState(new RelationshipTerminalState(record, relationName, projection)); + } + + @Override + public String toString() { + return this.getClass().getName(); + } + + private PersistentResource entityRecord(StateContext state, EntityContext entity) { + String id = entity.id().getText(); + String entityName = entity.term().getText(); + EntityDictionary dictionary = state.getRequestScope().getDictionary(); + Type entityClass = dictionary.getEntityClass(entityName, state.getRequestScope().getApiVersion()); + + if (entityClass == null || !dictionary.isRoot(entityClass)) { + throw new InvalidCollectionException(entityName); + } + + return PersistentResource.loadRecord(state.getRequestScope().getEntityProjection(), + id, state.getRequestScope()); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/state/StateContext.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StateContext.java similarity index 98% rename from elide-core/src/main/java/com/yahoo/elide/parsers/state/StateContext.java rename to elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StateContext.java index 57be6943a8..cd3e5d7696 100644 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/StateContext.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StateContext.java @@ -3,10 +3,9 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.parsers.state; +package com.yahoo.elide.jsonapi.parser.state; import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.generated.parsers.CoreParser.RootCollectionLoadEntitiesContext; import com.yahoo.elide.generated.parsers.CoreParser.RootCollectionLoadEntityContext; import com.yahoo.elide.generated.parsers.CoreParser.RootCollectionRelationshipContext; @@ -15,11 +14,10 @@ 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.JsonApiDocument; import com.fasterxml.jackson.databind.JsonNode; - -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; +import lombok.extern.slf4j.Slf4j; import java.util.function.Supplier; diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java new file mode 100644 index 0000000000..45833d2850 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java @@ -0,0 +1,182 @@ +/* + * 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.resources; + +import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.annotation.PATCH; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.utils.HeaderUtils; +import com.yahoo.elide.utils.ResourceUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; + +/** + * Default endpoint/servlet for using Elide and JSONAPI. + */ +@Singleton +@Produces(JSONAPI_CONTENT_TYPE) +@Path("/") +public class JsonApiEndpoint { + protected final Elide elide; + + @Inject + public JsonApiEndpoint( + @Named("elide") Elide elide) { + this.elide = elide; + } + + /** + * Create handler. + * + * @param path request path + * @param uriInfo URI info + * @param headers the request headers + * @param securityContext security context + * @param jsonapiDocument post data as jsonapi document + * @return response + */ + @POST + @Path("{path:.*}") + @Consumes(JSONAPI_CONTENT_TYPE) + public Response post( + @PathParam("path") String path, + @Context UriInfo uriInfo, + @Context HttpHeaders headers, + @Context SecurityContext securityContext, + String jsonapiDocument) { + MultivaluedMap queryParams = uriInfo.getQueryParameters(); + String apiVersion = HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); + Map> requestHeaders = + HeaderUtils.lowercaseAndRemoveAuthHeaders(headers.getRequestHeaders()); + User user = new SecurityContextUser(securityContext); + return build(elide.post(getBaseUrlEndpoint(uriInfo), path, jsonapiDocument, + queryParams, requestHeaders, user, apiVersion, UUID.randomUUID())); + } + + /** + * Read handler. + * + * @param path request path + * @param uriInfo URI info + * @param headers the request headers + * @param securityContext security context + * @return response + */ + @GET + @Path("{path:.*}") + public Response get( + @PathParam("path") String path, + @Context UriInfo uriInfo, + @Context HttpHeaders headers, + @Context SecurityContext securityContext) { + MultivaluedMap queryParams = uriInfo.getQueryParameters(); + String apiVersion = HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); + Map> requestHeaders = + HeaderUtils.lowercaseAndRemoveAuthHeaders(headers.getRequestHeaders()); + User user = new SecurityContextUser(securityContext); + + return build(elide.get(getBaseUrlEndpoint(uriInfo), path, queryParams, + requestHeaders, user, apiVersion, UUID.randomUUID())); + } + + /** + * Update handler. + * + * @param contentType document MIME type + * @param accept response MIME type + * @param path request path + * @param uriInfo URI info + * @param headers the request headers + * @param securityContext security context + * @param jsonapiDocument patch data as jsonapi document + * @return response + */ + @PATCH + @Path("{path:.*}") + @Consumes(JSONAPI_CONTENT_TYPE) + public Response patch( + @HeaderParam("Content-Type") String contentType, + @HeaderParam("accept") String accept, + @PathParam("path") String path, + @Context UriInfo uriInfo, + @Context HttpHeaders headers, + @Context SecurityContext securityContext, + String jsonapiDocument) { + MultivaluedMap queryParams = uriInfo.getQueryParameters(); + String apiVersion = HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); + Map> requestHeaders = + HeaderUtils.lowercaseAndRemoveAuthHeaders(headers.getRequestHeaders()); + User user = new SecurityContextUser(securityContext); + return build(elide.patch(getBaseUrlEndpoint(uriInfo), contentType, accept, path, + jsonapiDocument, queryParams, requestHeaders, user, apiVersion, UUID.randomUUID())); + } + + /** + * Delete relationship handler (expects body with resource ids and types). + * + * @param path request path + * @param uriInfo URI info + * @param headers the request headers + * @param securityContext security context + * @param jsonApiDocument DELETE document + * @return response + */ + @DELETE + @Path("{path:.*}") + @Consumes(JSONAPI_CONTENT_TYPE) + public Response delete( + @PathParam("path") String path, + @Context UriInfo uriInfo, + @Context HttpHeaders headers, + @Context SecurityContext securityContext, + String jsonApiDocument) { + MultivaluedMap queryParams = uriInfo.getQueryParameters(); + String apiVersion = + HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); + Map> requestHeaders = + HeaderUtils.lowercaseAndRemoveAuthHeaders(headers.getRequestHeaders()); + User user = new SecurityContextUser(securityContext); + return build(elide.delete(getBaseUrlEndpoint(uriInfo), path, jsonApiDocument, queryParams, requestHeaders, + user, apiVersion, UUID.randomUUID())); + } + + private static Response build(ElideResponse response) { + return Response.status(response.getResponseCode()).entity(response.getBody()).build(); + } + + protected String getBaseUrlEndpoint(UriInfo uriInfo) { + String baseUrl = elide.getElideSettings().getBaseUrl(); + + if (StringUtils.isEmpty(baseUrl)) { + //UriInfo has full path appended here already. + baseUrl = ResourceUtils.resolveBaseUrl(uriInfo); + } + + return baseUrl; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/SecurityContextUser.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/SecurityContextUser.java new file mode 100644 index 0000000000..731caf19d4 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/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.jsonapi.resources; + +import com.yahoo.elide.core.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/jsonapi/serialization/DataDeserializer.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataDeserializer.java index fc3e8c1216..b178dcdb41 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataDeserializer.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataDeserializer.java @@ -7,10 +7,10 @@ import com.yahoo.elide.jsonapi.models.Data; import com.yahoo.elide.jsonapi.models.Resource; - import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.MappingJsonFactory; import com.fasterxml.jackson.databind.ObjectMapper; @@ -23,21 +23,29 @@ * Custom deserializer for top-level data. */ public class DataDeserializer extends JsonDeserializer> { + private static final ObjectMapper MAPPER = new MappingJsonFactory().getCodec(); @Override public Data deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { JsonNode node = jsonParser.getCodec().readTree(jsonParser); - ObjectMapper mapper = new MappingJsonFactory().getCodec(); if (node.isArray()) { List resources = new ArrayList<>(); for (JsonNode n : node) { - Resource r = mapper.convertValue(n, Resource.class); + Resource r = MAPPER.convertValue(n, Resource.class); + validateResource(jsonParser, r); resources.add(r); } return new Data<>(resources); } - Resource resource = mapper.convertValue(node, Resource.class); + Resource resource = MAPPER.convertValue(node, Resource.class); + validateResource(jsonParser, resource); return new Data<>(resource); } + + private void validateResource(JsonParser jsonParser, Resource resource) throws IOException { + if (resource.getType() == null || resource.getType().isEmpty()) { + throw JsonMappingException.from(jsonParser, "Resource 'type' field is missing or empty."); + } + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataSerializer.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataSerializer.java index ab41b71882..6ad2055a43 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataSerializer.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataSerializer.java @@ -7,10 +7,11 @@ import com.yahoo.elide.jsonapi.models.Data; import com.yahoo.elide.jsonapi.models.Resource; - import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.IterableUtils; import java.io.IOException; import java.util.Collection; @@ -26,11 +27,11 @@ public void serialize(Data data, JsonGenerator jsonGenerator, Serializ throws IOException { Collection list = data.get(); if (data.isToOne()) { - if (list == null || list.isEmpty()) { + if (CollectionUtils.isEmpty(list)) { jsonGenerator.writeObject(null); return; } - jsonGenerator.writeObject(list.iterator().next()); + jsonGenerator.writeObject(IterableUtils.first(list)); return; } jsonGenerator.writeObject((list == null) ? Collections.emptyList() : list); diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/KeySerializer.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/KeySerializer.java index 3fc2e80b8b..b73204d2aa 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/KeySerializer.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/KeySerializer.java @@ -33,8 +33,7 @@ public void serialize(Object value, JsonGenerator jgen, SerializerProvider provi str = ((Class) value).getName(); } else if (cls.isEnum()) { str = ((Enum) value).name(); - } - else { + } else { str = value.toString(); } jgen.writeFieldName(str); diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/MetaDeserializer.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/MetaDeserializer.java new file mode 100644 index 0000000000..a03642068b --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/MetaDeserializer.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.jsonapi.serialization; + +import com.yahoo.elide.jsonapi.models.Meta; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MappingJsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.Map; + +/** + * Custom deserializer for top-level meta object. + */ +public class MetaDeserializer extends JsonDeserializer { + private static final ObjectMapper MAPPER = new MappingJsonFactory().getCodec(); + + @Override + public Meta deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) + throws IOException { + JsonNode node = jsonParser.getCodec().readTree(jsonParser); + // Optional top-level meta member must be an object + return node.isObject() ? new Meta(MAPPER.convertValue(node, Map.class)) : null; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/SingletonSerializer.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/SingletonSerializer.java deleted file mode 100644 index 152c40e724..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/SingletonSerializer.java +++ /dev/null @@ -1,25 +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.jsonapi.serialization; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; - -import java.io.IOException; -import java.util.AbstractCollection; - -/** - * Custom serializer for top-level data. - */ -public class SingletonSerializer extends JsonSerializer { - - @Override - public void serialize(AbstractCollection data, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) - throws IOException { - jsonGenerator.writeObject(data.iterator().next()); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/JsonApiParser.java b/elide-core/src/main/java/com/yahoo/elide/parsers/JsonApiParser.java deleted file mode 100644 index aa6f5ea4c3..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/JsonApiParser.java +++ /dev/null @@ -1,44 +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.parsers; - -import com.yahoo.elide.generated.parsers.CoreLexer; -import com.yahoo.elide.generated.parsers.CoreParser; -import org.antlr.v4.runtime.ANTLRInputStream; -import org.antlr.v4.runtime.BailErrorStrategy; -import org.antlr.v4.runtime.BaseErrorListener; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.RecognitionException; -import org.antlr.v4.runtime.Recognizer; -import org.antlr.v4.runtime.misc.ParseCancellationException; -import org.antlr.v4.runtime.tree.ParseTree; - -/** - * Parses the REST request. - */ -public class JsonApiParser { - - /** - * Compile request to AST. - * @param path request - * @return AST - */ - public static ParseTree parse(String path) { - ANTLRInputStream is = new ANTLRInputStream(path); - CoreLexer lexer = new CoreLexer(is); - lexer.removeErrorListeners(); - lexer.addErrorListener(new BaseErrorListener() { - @Override - public void syntaxError(Recognizer recognizer, Object offendingSymbol, int line, - int charPositionInLine, String msg, RecognitionException e) { - throw new ParseCancellationException(e); - } - }); - CoreParser parser = new CoreParser(new CommonTokenStream(lexer)); - parser.setErrorHandler(new BailErrorStrategy()); - return parser.start(); - } -} 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 deleted file mode 100644 index 0eae13fd1f..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/CanPaginateVisitor.java +++ /dev/null @@ -1,232 +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.parsers.expression; - -import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.core.CheckInstantiator; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.generated.parsers.ExpressionBaseVisitor; -import com.yahoo.elide.generated.parsers.ExpressionParser; -import com.yahoo.elide.security.FilterExpressionCheck; -import com.yahoo.elide.security.checks.Check; -import com.yahoo.elide.security.checks.UserCheck; -import org.antlr.v4.runtime.tree.ParseTree; - -import java.lang.annotation.Annotation; -import java.util.Collections; -import java.util.List; -import java.util.Set; - -/** - * Walks a permission expression to determine if any part of the expression must be evaluated in memory. - * If part of the expression must be evaluated in memory, the data store cannot paginate the result. - * - * Here are some examples of what the expected behavior is for combining user and filter expression checks: - * - * User Check (TRUE) OR Filter Expression (TRUE) - * - Elide will not push the filter predicate to the data store. - * - Elide will allow pagination. - * - The entire result set will be paginated. - * - The entire result set will be returned. - * - * User Check (TRUE) OR Filter Expression (FALSE) - * - Elide will not push the filter predicate to the data store. - * - Elide will allow pagination. - * - The entire result set will be paginated. - * - The entire result set will be returned. - * - * User Check (FALSE) OR Filter Expression (TRUE) - * - Elide will push the filter predicate to the data store. - * - Elide will allow pagination. - * - The filtered result set will be paginated. - * - The filtered result set will be returned. - * - * User Check (FALSE) OR Filter Expression (FALSE) - * - Elide will push the filter predicate to the data store. - * - Elide will allow pagination. - * - The empty result set will be paginated. - * - The empty result set will be returned. - * - * User Check (TRUE) AND Filter Expression (TRUE) - * - Elide will push the filter predicate to the data store. - * - Elide will allow pagination. - * - The filtered result set will be paginated. - * - The filtered result set will be returned. - * - * User Check (TRUE) AND Filter Expression (FALSE) - * - Elide will push the filter predicate to the data store. - * - Elide will allow pagination. - * - The empty result set will be paginated. - * - The empty result set will be returned. - * - * User Check (FALSE) AND Filter Expression (TRUE) - * - Elide will push the filter predicate to the data store. - * - Elide will allow pagination. - * - The filtered result set will be paginated. - * - The empty result set will be returned. - * - * User Check (FALSE) AND Filter Expression (FALSE) - * - Elide will push the filter predicate to the data store. - * - Elide will allow pagination. - * - The empty result set will be paginated. - * - The empty result set will be returned. - * - * More Complex Scenarios: - * - * (User Check (TRUE) OR Filter Expression 1 (FALSE)) AND Filter Expression 2 (TRUE) - * - Elide WILL push the filter predicate to the data store. - * - Elide will allow pagination. - * - The filtered (2) result set will be paginated. - * - The filtered (2) result set will be returned. - * - * (User Check (TRUE) OR Filter Expression 1 (TRUE)) AND Filter Expression 2 (TRUE) - * - Elide WILL push the filter predicate (2) to the data store. - * - Elide will allow pagination. - * - The filtered (2) result set will be paginated. - * - The filtered (2) result set will be returned. - * - * (User Check (FALSE) OR Filter Expression 1 (TRUE)) AND Filter Expression 2 (TRUE) - * - Elide WILL push the filter predicate (1 and 2) to the data store. - * - Elide will allow pagination. - * - The filtered (1 and 2) result set will be paginated. - * - The filtered (1 and 2) result set will be returned. - * - * (User Check (FALSE) OR Filter Expression 1 (FALSE)) AND Filter Expression 2 (TRUE) - * - Elide WILL push the filter predicate (1 and 2) to the data store. - * - Elide will allow pagination. - * - The empty result set will be paginated. - * - The empty result set will be returned. - * - * (User Check (TRUE) AND Filter Expression 1 (FALSE)) OR Filter Expression 2 (TRUE) - * - Elide WILL push the filter predicate (1 or 2) to the data store. - * - Elide will allow pagination. - * - The filtered (2) result set will be paginated. - * - The filtered (2) result set will be returned. - * - * (User Check (TRUE) AND Filter Expression 1 (TRUE)) OR Filter Expression 2 (TRUE) - * - Elide WILL push the filter predicate (1 or 2) to the data store. - * - Elide will allow pagination. - * - The filtered (1 or 2) result set will be paginated. - * - The filtered (1 or 2) result set will be returned. - * - * (User Check (FALSE) AND Filter Expression 1 (TRUE)) OR Filter Expression 2 (TRUE) - * - Elide WILL push the filter predicate (2) to the data store. - * - Elide will allow pagination. - * - The filtered (2) result set will be paginated. - * - The filtered (2) result set will be returned - * - * (User Check (FALSE) AND Filter Expression 1 (FALSE)) OR Filter Expression 2 (TRUE) - * - Elide WILL push the filter predicate (2) to the data store. - * - Elide will allow pagination. - * - The filtered (2) result set will be paginated. - * - The filtered (2) result set will be returned - * - */ -public class CanPaginateVisitor extends ExpressionBaseVisitor implements CheckInstantiator { - - public static final Boolean CAN_PAGINATE = true; - public static final Boolean CANNOT_PAGINATE = false; - - private final EntityDictionary dictionary; - - - public CanPaginateVisitor(EntityDictionary dictionary) { - this.dictionary = dictionary; - } - - - @Override - public Boolean visitNOT(ExpressionParser.NOTContext ctx) { - return visit(ctx.expression()); - } - - @Override - public Boolean visitOR(ExpressionParser.ORContext ctx) { - boolean lhs = visit(ctx.left); - boolean rhs = visit(ctx.right); - - /* If either side requires in memory filtering, the data store cannot paginate */ - if (lhs == CANNOT_PAGINATE || rhs == CANNOT_PAGINATE) { - return CANNOT_PAGINATE; - } - return CAN_PAGINATE; - } - - @Override - public Boolean visitAND(ExpressionParser.ANDContext ctx) { - boolean lhs = visit(ctx.left); - boolean rhs = visit(ctx.right); - - /* If either side requires in memory filtering, the data store cannot paginate */ - if (lhs == CANNOT_PAGINATE || rhs == CANNOT_PAGINATE) { - return CANNOT_PAGINATE; - } - return CAN_PAGINATE; - } - - @Override - public Boolean visitPAREN(ExpressionParser.PARENContext ctx) { - return visit(ctx.expression()); - } - - @Override - public Boolean visitPermissionClass(ExpressionParser.PermissionClassContext ctx) { - Check check = getCheck(dictionary, ctx.getText()); - - //Filter expression checks can always be pushed to the DataStore so pagination is possible - if (FilterExpressionCheck.class.isAssignableFrom(check.getClass())) { - return CAN_PAGINATE; - - //User Checks have no bearing on pagination since they are true or false for every item in the collection - } else if (UserCheck.class.isAssignableFrom(check.getClass())) { - return CAN_PAGINATE; - //Any in memory check will alter (incorrectly) the paginated result - } else { - return CANNOT_PAGINATE; - } - } - - /** - * Determines whether a data store can correctly paginate a collection of resources of a given - * class for a requested set of fields. - * @param resourceClass The class of resources that will be paginated - * @param dictionary Used to look up permissions - * @param scope Contains the request info including any sparse fields that were requested - * @return true if the data store can paginate. false otherwise. - */ - public static boolean canPaginate(Class resourceClass, EntityDictionary dictionary, RequestScope scope) { - - CanPaginateVisitor visitor = new CanPaginateVisitor(dictionary); - - Class annotationClass = ReadPermission.class; - ParseTree classPermissions = dictionary.getPermissionsForClass(resourceClass, annotationClass); - Boolean canPaginateClass = CAN_PAGINATE; - if (classPermissions != null) { - canPaginateClass = visitor.visit(classPermissions); - } - - List fields = dictionary.getAllFields(resourceClass); - String resourceName = dictionary.getJsonAliasFor(resourceClass); - Set requestedFields = scope.getSparseFields().getOrDefault(resourceName, Collections.EMPTY_SET); - - for (String field : fields) { - if (! requestedFields.isEmpty() && ! requestedFields.contains(field)) { - continue; - } - Boolean canPaginateField = canPaginateClass; - ParseTree fieldPermissions = dictionary.getPermissionsForField(resourceClass, field, annotationClass); - if (fieldPermissions != null) { - canPaginateField = visitor.visit(fieldPermissions); - } - if (canPaginateField == CANNOT_PAGINATE) { - return CANNOT_PAGINATE; - } - } - return CAN_PAGINATE; - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/CriterionExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/parsers/expression/CriterionExpressionVisitor.java deleted file mode 100644 index 6bceccca25..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/CriterionExpressionVisitor.java +++ /dev/null @@ -1,105 +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.parsers.expression; - -import com.yahoo.elide.core.CheckInstantiator; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.generated.parsers.ExpressionBaseVisitor; -import com.yahoo.elide.generated.parsers.ExpressionParser; -import com.yahoo.elide.security.RequestScope; -import com.yahoo.elide.security.checks.Check; -import com.yahoo.elide.security.checks.CriterionCheck; - -import java.util.function.BiFunction; -import java.util.function.Function; - - -/** - * Returns a Criterion (user specified type) from a security expression. - * @param the return type of the CriterionCheck - */ -public class CriterionExpressionVisitor extends ExpressionBaseVisitor implements CheckInstantiator { - - - private final RequestScope requestScope; - private final EntityDictionary entityDictionary; - private final Function criterionNegater; - private final BiFunction orCriterionJoiner; - private final BiFunction andCriterionJoiner; - - public CriterionExpressionVisitor(RequestScope requestScope, - EntityDictionary entityDictionary, - Function criterionNegater, - BiFunction orCriterionJoiner, - BiFunction andCriterionJoiner) { - this.requestScope = requestScope; - this.entityDictionary = entityDictionary; - - this.criterionNegater = criterionNegater; - this.orCriterionJoiner = orCriterionJoiner; - this.andCriterionJoiner = andCriterionJoiner; - } - - @Override - public T visitNOT(ExpressionParser.NOTContext ctx) { - T criterion = visit(ctx); - - if (criterion != null) { - criterion = criterionNegater.apply(criterion); - } - - return criterion; - } - - @Override - public T visitOR(ExpressionParser.ORContext ctx) { - return joinSubexpressions(ctx.left, ctx.right, orCriterionJoiner); - } - - @Override - public T visitAND(ExpressionParser.ANDContext ctx) { - return joinSubexpressions(ctx.left, ctx.right, andCriterionJoiner); - } - - @Override - public T visitPermissionClass(ExpressionParser.PermissionClassContext ctx) { - CriterionCheck criterionCheck = getCriterionCheck(ctx.getText()); - - if (criterionCheck == null) { - return null; - } - - return criterionCheck.getCriterion(requestScope); - } - - private T joinSubexpressions(ExpressionParser.ExpressionContext left, - ExpressionParser.ExpressionContext right, - BiFunction joiner) { - T leftCriterion = visit(left); - T rightCriterion = visit(right); - - if (leftCriterion == null && rightCriterion == null) { - return null; - - } else if (leftCriterion == null) { - return rightCriterion; - - } else if (rightCriterion == null) { - return leftCriterion; - } - - return joiner.apply(leftCriterion, rightCriterion); - } - - private CriterionCheck getCriterionCheck(String checkName) { - Check check = getCheck(entityDictionary, checkName); - - return check instanceof CriterionCheck - ? (CriterionCheck) check - : null; - - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/PermissionExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/parsers/expression/PermissionExpressionVisitor.java deleted file mode 100644 index 98b0e89057..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/PermissionExpressionVisitor.java +++ /dev/null @@ -1,64 +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.parsers.expression; - -import com.yahoo.elide.core.CheckInstantiator; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.generated.parsers.ExpressionBaseVisitor; -import com.yahoo.elide.generated.parsers.ExpressionParser; -import com.yahoo.elide.security.checks.Check; -import com.yahoo.elide.security.permissions.expressions.AndExpression; -import com.yahoo.elide.security.permissions.expressions.Expression; -import com.yahoo.elide.security.permissions.expressions.NotExpression; -import com.yahoo.elide.security.permissions.expressions.OrExpression; - -import java.util.function.Function; - -/** - * Expression Visitor. - */ -public class PermissionExpressionVisitor extends ExpressionBaseVisitor implements CheckInstantiator { - private final EntityDictionary dictionary; - private final Function expressionGenerator; - - - public PermissionExpressionVisitor(EntityDictionary dictionary, Function expressionGenerator) { - this.dictionary = dictionary; - this.expressionGenerator = expressionGenerator; - } - - - @Override - public Expression visitNOT(ExpressionParser.NOTContext ctx) { - // Create a not expression - return new NotExpression(visit(ctx.expression())); - } - - @Override - public Expression visitOR(ExpressionParser.ORContext ctx) { - return new OrExpression(visit(ctx.left), visit(ctx.right)); - } - - @Override - public Expression visitAND(ExpressionParser.ANDContext ctx) { - Expression left = visit(ctx.left); - Expression right = visit(ctx.right); - return new AndExpression(left, right); - } - - @Override - public Expression visitPAREN(ExpressionParser.PARENContext ctx) { - return visit(ctx.expression()); - } - - @Override - public Expression visitPermissionClass(ExpressionParser.PermissionClassContext ctx) { - Check check = getCheck(dictionary, ctx.getText()); - - return expressionGenerator.apply(check); - } -} 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 deleted file mode 100644 index 78c5f73b29..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/expression/PermissionToFilterExpressionVisitor.java +++ /dev/null @@ -1,121 +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.parsers.expression; - -import com.yahoo.elide.core.CheckInstantiator; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.filter.expression.AndFilterExpression; -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.filter.expression.OrFilterExpression; -import com.yahoo.elide.core.filter.expression.Visitor; -import com.yahoo.elide.generated.parsers.ExpressionBaseVisitor; -import com.yahoo.elide.generated.parsers.ExpressionParser; -import com.yahoo.elide.security.FilterExpressionCheck; -import com.yahoo.elide.security.RequestScope; -import com.yahoo.elide.security.checks.Check; -import com.yahoo.elide.security.checks.UserCheck; - - -/** - * PermissionToFilterExpressionVisitor parses a permission parseTree and returns the corresponding FilterExpression - * representation of it. This allows passing a security permission predicate down to datastore level to reduce - * in-memory permission verification workload. - * A few cases is not allow and will throw exception: - * 1. User define FilterExpressionCheck which returns null in getFilterExpression function. - * 2. User put a FilterExpressionCheck with a non-userCheck type check in OR relation. - */ -public class PermissionToFilterExpressionVisitor extends ExpressionBaseVisitor - implements CheckInstantiator { - private final EntityDictionary dictionary; - private final RequestScope requestScope; - - public static final FilterExpression NO_EVALUATION_EXPRESSION = new FilterExpression() { - @Override - public T accept(Visitor visitor) { - return null; - } - }; - - public static final FilterExpression FALSE_USER_CHECK_EXPRESSION = new FilterExpression() { - @Override - public T accept(Visitor visitor) { - return null; - } - }; - - public PermissionToFilterExpressionVisitor(EntityDictionary dictionary, RequestScope requestScope) { - this.dictionary = dictionary; - this.requestScope = requestScope; - } - - @Override - public FilterExpression visitOR(ExpressionParser.ORContext ctx) { - FilterExpression left = visit(ctx.left); - FilterExpression right = visit(ctx.right); - - if (left == NO_EVALUATION_EXPRESSION || right == NO_EVALUATION_EXPRESSION) { - return NO_EVALUATION_EXPRESSION; - } - - if (left == FALSE_USER_CHECK_EXPRESSION) { - return right; - } else if (right == FALSE_USER_CHECK_EXPRESSION) { - return left; - } - - return new OrFilterExpression(left, right); - } - - @Override - public FilterExpression visitPermissionClass(ExpressionParser.PermissionClassContext ctx) { - Check check = getCheck(dictionary, ctx.getText()); - if (FilterExpressionCheck.class.isAssignableFrom(check.getClass())) { - FilterExpression filterExpression = ((FilterExpressionCheck) check).getFilterExpression(requestScope); - if (filterExpression == null) { - throw new IllegalStateException("FilterExpression null is not permitted."); - } - return ((FilterExpressionCheck) check).getFilterExpression(requestScope); - } else if (UserCheck.class.isAssignableFrom(check.getClass())) { - boolean userCheckResult = check.ok(requestScope.getUser()); - return userCheckResult ? NO_EVALUATION_EXPRESSION : FALSE_USER_CHECK_EXPRESSION; - } else { - return NO_EVALUATION_EXPRESSION; - } - } - - @Override - public FilterExpression visitAND(ExpressionParser.ANDContext ctx) { - FilterExpression left = visit(ctx.left); - FilterExpression right = visit(ctx.right); - - //Case (FALSE_USER_CHECK_EXPRESSION AND FE): should evaluate to FALSE_USER_CHECK_EXPRESSION - //Case (FALSE_USER_CHECK_EXPRESSION AND NO_EVALUATION_EXPRESSION): should also evaluate to - // FALSE_USER_CHECK_EXPRESSION - if (left == FALSE_USER_CHECK_EXPRESSION || right == FALSE_USER_CHECK_EXPRESSION) { - return FALSE_USER_CHECK_EXPRESSION; - } - - //Case (NO_EVALUATION_EXPRESSION AND FilterExpression): should ignore NO_EVALUATION_EXPRESSION and return - // FilterExpression. - //Case (NO_EVALUATION_EXPRESSION AND NO_EVALUATION_EXPRESSION): returns NO_EVALUATION_EXPRESSION. - if (left == NO_EVALUATION_EXPRESSION) { - return right; - } - - if (right == NO_EVALUATION_EXPRESSION) { - return left; - } - - //Case (FilterExpression AND FilterExpression): should return the AND expression of them. - return new AndFilterExpression(left, right); - } - - @Override - public FilterExpression visitPAREN(ExpressionParser.PARENContext ctx) { - return visit(ctx.expression()); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/state/BaseState.java b/elide-core/src/main/java/com/yahoo/elide/parsers/state/BaseState.java deleted file mode 100644 index 7677214d2a..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/BaseState.java +++ /dev/null @@ -1,153 +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.parsers.state; - -import com.yahoo.elide.core.exceptions.HttpStatusException; -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.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.fasterxml.jackson.databind.JsonNode; -import org.apache.commons.lang3.tuple.Pair; - -import java.util.function.Supplier; - -/** - * Base class for state information. - */ -public abstract class BaseState { - - /** - * Handle void. - * - * @param state the state - * @param ctx the ctx - */ - public void handle (StateContext state, RootCollectionLoadEntitiesContext ctx) { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * Handle void. - * - * @param state the state - * @param ctx the ctx - */ - public void handle (StateContext state, RootCollectionLoadEntityContext ctx) { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * Handle void. - * - * @param state the state - * @param ctx the ctx - */ - public void handle (StateContext state, RootCollectionSubCollectionContext ctx) { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * Handle void. - * - * @param state the state - * @param ctx the ctx - */ - public void handle(StateContext state, RootCollectionRelationshipContext ctx) { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * Handle void. - * - * @param state the state - * @param ctx the ctx - */ - public void handle (StateContext state, SubCollectionReadCollectionContext ctx) { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * Handle void. - * - * @param state the state - * @param ctx the ctx - */ - public void handle (StateContext state, SubCollectionReadEntityContext ctx) { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * Handle void. - * - * @param state the state - * @param ctx the ctx - */ - public void handle (StateContext state, SubCollectionSubCollectionContext ctx) { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - - /** - * Handle void. - * - * @param state the state - * @param ctx the ctx - */ - public void handle(StateContext state, SubCollectionRelationshipContext ctx) { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * We return a Function because we may have to perform post-commit operations. That is, - * we may need to perform extra operations after having closed a transaction. As a result, - * this method is invoked after committing a transaction in Elide.java. - * @param state the state - * @return the supplier - * @throws HttpStatusException the http status exception - */ - public Supplier> handleGet(StateContext state) throws HttpStatusException { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * Handle patch. - * - * @param state the state - * @return the supplier - * @throws HttpStatusException the http status exception - */ - public Supplier> handlePatch(StateContext state) throws HttpStatusException { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * Handle post. - * - * @param state the state - * @return the supplier - * @throws HttpStatusException the http status exception - */ - public Supplier> handlePost(StateContext state) throws HttpStatusException { - throw new UnsupportedOperationException(this.getClass().toString()); - } - - /** - * Handle delete. - * - * @param state the state - * @return the supplier - * @throws HttpStatusException the http status exception - */ - public Supplier> handleDelete(StateContext state) throws HttpStatusException { - throw new UnsupportedOperationException(this.getClass().toString()); - } -} 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 deleted file mode 100644 index 53f91afae3..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/CollectionTerminalState.java +++ /dev/null @@ -1,225 +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.parsers.state; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.base.Preconditions; -import com.yahoo.elide.core.HttpStatus; -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.InternalServerErrorException; -import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; -import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException; -import com.yahoo.elide.core.exceptions.InvalidValueException; -import com.yahoo.elide.core.pagination.Pagination; -import com.yahoo.elide.jsonapi.JsonApiMapper; -import com.yahoo.elide.jsonapi.document.processors.DocumentProcessor; -import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; -import com.yahoo.elide.jsonapi.models.Data; -import com.yahoo.elide.jsonapi.models.JsonApiDocument; -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.security.User; -import lombok.ToString; -import org.apache.commons.lang3.tuple.Pair; - -import javax.ws.rs.core.MultivaluedMap; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -/** - * Collection State. - */ -@ToString -public class CollectionTerminalState extends BaseState { - private final Optional parent; - private final Optional relationName; - private final Class entityClass; - private PersistentResource newObject; - - public CollectionTerminalState(Class entityClass, Optional parent, - Optional relationName) { - this.parent = parent; - this.relationName = relationName; - this.entityClass = entityClass; - } - - @Override - public Supplier> handleGet(StateContext state) { - JsonApiDocument jsonApiDocument = new JsonApiDocument(); - RequestScope requestScope = state.getRequestScope(); - ObjectMapper mapper = requestScope.getMapper().getObjectMapper(); - Optional> queryParams = requestScope.getQueryParams(); - - Set collection = getResourceCollection(requestScope); - // Set data - jsonApiDocument.setData(getData(requestScope, collection)); - - // Run include processor - DocumentProcessor includedProcessor = new IncludedProcessor(); - includedProcessor.execute(jsonApiDocument, collection, queryParams); - - // Add pagination meta data - Pagination pagination = requestScope.getPagination(); - if (!pagination.isEmpty()) { - - 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()) { - Long totalRecords = PersistentResource.getTotalRecords(entityClass, requestScope); - pageMetaData.put("totalPages", totalRecords / pagination.getLimit() - + ((totalRecords % pagination.getLimit()) > 0 ? 1 : 0)); - pageMetaData.put("totalRecords", totalRecords); - } - - Map allMetaData = new HashMap<>(); - allMetaData.put("page", pageMetaData); - - Meta meta = new Meta(allMetaData); - jsonApiDocument.setMeta(meta); - } - - JsonNode responseBody = mapper.convertValue(jsonApiDocument, JsonNode.class); - - return () -> Pair.of(HttpStatus.SC_OK, responseBody); - } - - @Override - public Supplier> handlePost(StateContext state) { - RequestScope requestScope = state.getRequestScope(); - JsonApiMapper mapper = requestScope.getMapper(); - - newObject = createObject(requestScope); - if (parent.isPresent()) { - parent.get().addRelation(relationName.get(), newObject); - } - return () -> { - JsonApiDocument returnDoc = new JsonApiDocument(); - returnDoc.setData(new Data(newObject.toResource())); - JsonNode responseBody = mapper.getObjectMapper().convertValue(returnDoc, JsonNode.class); - return Pair.of(HttpStatus.SC_CREATED, responseBody); - }; - } - - private Set getResourceCollection(RequestScope requestScope) { - final Set collection; - // TODO: In case of join filters, apply pagination after getting records - // instead of passing it to the datastore - final boolean hasSortingOrPagination = requestScope.getPagination() != null - || requestScope.getSorting() != null; - if (parent.isPresent()) { - if (hasSortingOrPagination) { - collection = parent.get().getRelationCheckedFilteredWithSortingAndPagination(relationName.get()); - } else { - collection = parent.get().getRelationCheckedFiltered(relationName.get()); - } - } else { - if (hasSortingOrPagination) { - collection = (Set) PersistentResource.loadRecordsWithSortingAndPagination(entityClass, requestScope); - } else { - collection = (Set) PersistentResource.loadRecords(entityClass, requestScope); - } - } - - return collection; - } - - private Data getData(RequestScope requestScope, Set collection) { - User user = requestScope.getUser(); - Preconditions.checkNotNull(collection); - Preconditions.checkNotNull(user); - - List resources = collection.stream().map(PersistentResource::toResource).collect(Collectors.toList()); - - return new Data<>(resources); - } - - private PersistentResource createObject(RequestScope requestScope) - throws ForbiddenAccessException, InvalidObjectIdentifierException { - JsonApiDocument doc = requestScope.getJsonApiDocument(); - JsonApiMapper mapper = requestScope.getMapper(); - - Data data = doc.getData(); - Collection resources = data.get(); - - Resource resource = (resources.size() == 1) ? resources.iterator().next() : null; - if (resource == null) { - try { - throw new InvalidEntityBodyException(mapper.writeJsonApiDocument(doc)); - } catch (JsonProcessingException e) { - throw new InternalServerErrorException(e); - } - } - - String id = resource.getId(); - PersistentResource pResource; - if (parent.isPresent()) { - pResource = PersistentResource.createObject(parent.get(), entityClass, requestScope, id); - } else { - pResource = PersistentResource.createObject(entityClass, requestScope, id); - } - - assignId(pResource, id); - - Map attributes = resource.getAttributes(); - if (attributes != null) { - for (Map.Entry entry : attributes.entrySet()) { - String fieldName = entry.getKey(); - Object val = entry.getValue(); - pResource.updateAttribute(fieldName, val); - } - } - - Map relationships = resource.getRelationships(); - if (relationships != null) { - for (Map.Entry entry : relationships.entrySet()) { - String fieldName = entry.getKey(); - Relationship relationship = entry.getValue(); - Set resourceSet = (relationship == null) - ? null - : relationship.toPersistentResources(requestScope); - pResource.updateRelation(fieldName, resourceSet); - } - } - - return pResource; - } - - /** - * Assign provided id if id field is not generated. - * - * @param persistentResource resource - * @param id resource id - */ - private void assignId(PersistentResource persistentResource, String id) { - - //If id field is not a `@GeneratedValue` persist the provided id - if (!persistentResource.isIdGenerated()) { - if (id != null && !id.isEmpty()) { - persistentResource.setId(id); - } else { - //If expecting id to persist and id is not present, throw exception - throw new InvalidValueException( - persistentResource.toResource(), - "No id provided, cannot persist " + persistentResource.getObject() - ); - } - } - } -} 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 deleted file mode 100644 index 6e13a3581f..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/RecordState.java +++ /dev/null @@ -1,114 +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.parsers.state; - -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.jsonapi.models.SingleElementSet; -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.google.common.base.Preconditions; - -import java.util.Optional; -import java.util.Set; - -/** - * Record Read State. - */ -public class RecordState extends BaseState { - private final PersistentResource resource; - - public RecordState(PersistentResource resource) { - Preconditions.checkNotNull(resource); - this.resource = resource; - } - - @Override - public void handle(StateContext state, SubCollectionReadCollectionContext ctx) { - String subCollection = ctx.term().getText(); - EntityDictionary dictionary = state.getRequestScope().getDictionary(); - try { - RelationshipType type = dictionary.getRelationshipType(resource.getObject(), subCollection); - if (type == RelationshipType.NONE) { - throw new InvalidCollectionException(subCollection); - } - String entityName = - dictionary.getJsonAliasFor(dictionary.getParameterizedType(resource.getObject(), subCollection)); - Class 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()) { - collection = resource.getRelationCheckedFiltered(subCollection); - } - 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); - } - } - - @Override - 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); - } - } - - @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); - } - } - - @Override - public void handle(StateContext state, SubCollectionRelationshipContext ctx) { - String id = ctx.entity().id().getText(); - String subCollection = ctx.entity().term().getText(); - - PersistentResource childRecord; - try { - childRecord = resource.getRelation(subCollection, id); - } catch (InvalidAttributeException e) { - throw new InvalidCollectionException(subCollection); - } - - String relationName = ctx.relationship().term().getText(); - try { - childRecord.getRelationCheckedFiltered(relationName); - } catch (InvalidAttributeException e) { - throw new InvalidCollectionException(relationName); - } - - state.setState(new RelationshipTerminalState(childRecord, relationName)); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/parsers/state/RecordTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/parsers/state/RecordTerminalState.java deleted file mode 100644 index 83dfffc604..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/RecordTerminalState.java +++ /dev/null @@ -1,139 +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.parsers.state; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.yahoo.elide.core.HttpStatus; -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.InvalidEntityBodyException; -import com.yahoo.elide.core.exceptions.InvalidOperationException; -import com.yahoo.elide.jsonapi.document.processors.DocumentProcessor; -import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; -import com.yahoo.elide.jsonapi.models.Data; -import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import com.yahoo.elide.jsonapi.models.Relationship; -import com.yahoo.elide.jsonapi.models.Resource; -import lombok.ToString; -import org.apache.commons.lang3.tuple.Pair; - -import javax.ws.rs.core.MultivaluedMap; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Supplier; - -/** - * Record Found State. - */ -@ToString -public class RecordTerminalState extends BaseState { - private final PersistentResource record; - private final Optional collectionTerminalState; - - public RecordTerminalState(PersistentResource record) { - this(record, null); - } - - // This constructor is to handle ToOne collection as a Record Terminal State - public RecordTerminalState(PersistentResource record, CollectionTerminalState collectionTerminalState) { - this.record = record; - this.collectionTerminalState = Optional.ofNullable(collectionTerminalState); - } - - @Override - public Supplier> handleGet(StateContext state) { - ObjectMapper mapper = state.getRequestScope().getMapper().getObjectMapper(); - return () -> Pair.of(HttpStatus.SC_OK, getResponseBody(record, state.getRequestScope(), mapper)); - } - - @Override - public Supplier> handlePost(StateContext state) { - if (collectionTerminalState.isPresent()) { - return collectionTerminalState.get().handlePost(state); - } - throw new InvalidOperationException("Cannot POST to a record."); - } - - @Override - public Supplier> handlePatch(StateContext state) { - JsonApiDocument jsonApiDocument = state.getJsonApiDocument(); - - Data data = jsonApiDocument.getData(); - - if (data == null) { - throw new InvalidEntityBodyException("Expected data but found null"); - } - - if (!data.isToOne()) { - throw new InvalidEntityBodyException("Expected single element but found list"); - } - - Resource resource = data.getSingleValue(); - if (!record.matchesId(resource.getId())) { - throw new InvalidEntityBodyException("Id in response body does not match requested id to update from path"); - } - - patch(resource, state.getRequestScope()); - return () -> Pair.of(HttpStatus.SC_NO_CONTENT, null); - } - - @Override - public Supplier> handleDelete(StateContext state) { - try { - record.deleteResource(); - return () -> Pair.of(HttpStatus.SC_NO_CONTENT, null); - } catch (ForbiddenAccessException e) { - return () -> Pair.of(e.getStatus(), null); - } - } - - private JsonNode getResponseBody(PersistentResource rec, RequestScope requestScope, ObjectMapper mapper) { - Optional> queryParams = requestScope.getQueryParams(); - JsonApiDocument jsonApiDocument = new JsonApiDocument(); - - //TODO Make this a document processor - Data data = rec == null ? null : new Data<>(rec.toResource()); - jsonApiDocument.setData(data); - - //TODO Iterate over set of document processors - DocumentProcessor includedProcessor = new IncludedProcessor(); - includedProcessor.execute(jsonApiDocument, rec, queryParams); - - return mapper.convertValue(jsonApiDocument, JsonNode.class); - } - - private boolean patch(Resource resource, RequestScope requestScope) { - boolean isUpdated = false; - - // Update attributes first - Map attributes = resource.getAttributes(); - if (attributes != null) { - for (Map.Entry entry : attributes.entrySet()) { - String fieldName = entry.getKey(); - Object newVal = entry.getValue(); - isUpdated |= record.updateAttribute(fieldName, newVal); - } - } - - // Relations next - Map relationships = resource.getRelationships(); - if (relationships != null) { - for (Map.Entry entry : relationships.entrySet()) { - String fieldName = entry.getKey(); - Relationship relationship = entry.getValue(); - Set resources = (relationship == null) - ? null - : relationship.toPersistentResources(requestScope); - isUpdated |= record.updateRelation(fieldName, resources); - } - } - - return isUpdated; - } -} 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 deleted file mode 100644 index f7eeed6e0d..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/RelationshipTerminalState.java +++ /dev/null @@ -1,168 +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.parsers.state; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.yahoo.elide.core.HttpStatus; -import com.yahoo.elide.core.PersistentResource; -import com.yahoo.elide.core.RelationshipType; -import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.core.exceptions.ForbiddenAccessException; -import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; -import com.yahoo.elide.jsonapi.document.processors.DocumentProcessor; -import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; -import com.yahoo.elide.jsonapi.models.Data; -import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import com.yahoo.elide.jsonapi.models.Relationship; -import com.yahoo.elide.jsonapi.models.Resource; -import org.apache.commons.lang3.tuple.Pair; - -import javax.ws.rs.core.MultivaluedMap; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Map; -import java.util.Optional; -import java.util.function.BiFunction; -import java.util.function.Supplier; - -/** - * State to handle relationships. - */ -public class RelationshipTerminalState extends BaseState { - private final PersistentResource record; - private final RelationshipType relationshipType; - private final String relationshipName; - - public RelationshipTerminalState(PersistentResource record, String relationshipName) { - this.record = record; - - this.relationshipType = record.getRelationshipType(relationshipName); - this.relationshipName = relationshipName; - } - - @Override - public Supplier> handleGet(StateContext state) { - JsonApiDocument doc = new JsonApiDocument(); - RequestScope requestScope = state.getRequestScope(); - ObjectMapper mapper = requestScope.getMapper().getObjectMapper(); - Optional> queryParams = requestScope.getQueryParams(); - - Map relationships = record.toResourceWithSortingAndPagination().getRelationships(); - Relationship relationship = null; - if (relationships != null) { - relationship = relationships.get(relationshipName); - } - - // Handle valid relationship - if (relationship != null) { - - // Set data - Data data = relationship.getData(); - doc.setData(data); - - // Run include processor - DocumentProcessor includedProcessor = new IncludedProcessor(); - includedProcessor.execute(doc, record, queryParams); - - return () -> Pair.of(HttpStatus.SC_OK, mapper.convertValue(doc, JsonNode.class)); - } - - // Handle no data for relationship - if (relationshipType.isToMany()) { - doc.setData(new Data<>(new ArrayList<>())); - } else if (relationshipType.isToOne()) { - doc.setData(new Data<>((Resource) null)); - } else { - throw new IllegalStateException("Failed to GET a relationship; relationship is neither toMany nor toOne"); - } - return () -> Pair.of(HttpStatus.SC_OK, mapper.convertValue(doc, JsonNode.class)); - } - - @Override - public Supplier> handlePatch(StateContext state) { - return handleRequest(state, this::patch); - } - - @Override - public Supplier> handlePost(StateContext state) { - return handleRequest(state, this::post); - } - - @Override - public Supplier> handleDelete(StateContext state) { - return handleRequest(state, this::delete); - } - - private Supplier> handleRequest(StateContext state, - BiFunction, RequestScope, Boolean> handler) { - Data data = state.getJsonApiDocument().getData(); - handler.apply(data, state.getRequestScope()); - return () -> Pair.of(HttpStatus.SC_NO_CONTENT, null); - } - - private boolean patch(Data data, RequestScope requestScope) { - boolean isUpdated; - - if (relationshipType.isToMany() && data == null) { - throw new InvalidEntityBodyException("Expected data but received null"); - } - - if (relationshipType.isToMany()) { - Collection resources = data.get(); - if (resources == null) { - return false; - } - if (!resources.isEmpty()) { - isUpdated = record.updateRelation(relationshipName, - new Relationship(null, new Data<>(resources)).toPersistentResources(requestScope)); - } else { - isUpdated = record.clearRelation(relationshipName); - } - } else if (relationshipType.isToOne()) { - if (data != null) { - Resource resource = data.getSingleValue(); - Relationship relationship = new Relationship(null, new Data<>(resource)); - isUpdated = record.updateRelation(relationshipName, relationship.toPersistentResources(requestScope)); - } else { - isUpdated = record.clearRelation(relationshipName); - } - } else { - throw new IllegalStateException("Bad relationship type"); - } - - return isUpdated; - } - - private boolean post(Data data, RequestScope requestScope) { - if (data == null) { - throw new InvalidEntityBodyException("Expected data but received null"); - } - - Collection resources = data.get(); - if (resources == null) { - return false; - } - resources.stream().forEachOrdered(resource -> - record.addRelation(relationshipName, resource.toPersistentResource(requestScope))); - return true; - } - - private boolean delete(Data data, RequestScope requestScope) { - if (data == null) { - throw new InvalidEntityBodyException("Expected data but received null"); - } - - Collection resources = data.get(); - if (resources == null || resources.isEmpty()) { - // As per: http://jsonapi.org/format/#crud-updating-relationship-responses-403 - throw new ForbiddenAccessException("Unknown update"); - } - resources.stream().forEachOrdered(resource -> - record.removeRelation(relationshipName, resource.toPersistentResource(requestScope))); - return true; - } -} 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 deleted file mode 100644 index ab3ec0f6d7..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/parsers/state/StartState.java +++ /dev/null @@ -1,89 +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.parsers.state; - -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.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 java.util.Optional; - -/** - * Initial State. - */ -public class StartState extends BaseState { - @Override - 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())); - } - - @Override - public void handle(StateContext state, RootCollectionLoadEntityContext ctx) { - EntityDictionary dictionary = state.getRequestScope().getDictionary(); - String entityName = ctx.entity().term().getText(); - String id = ctx.entity().id().getText(); - Class entityClass = dictionary.getEntityClass(entityName); - if (entityClass == null || !dictionary.isRoot(entityClass)) { - throw new InvalidCollectionException(entityName); - } - - PersistentResource record = PersistentResource.loadRecord(entityClass, id, state.getRequestScope()); - state.setState(new RecordTerminalState(record)); - } - - @Override - public void handle(StateContext state, RootCollectionSubCollectionContext ctx) { - EntityDictionary dictionary = state.getRequestScope().getDictionary(); - String entityName = ctx.entity().term().getText(); - String id = ctx.entity().id().getText(); - - Class entityClass = dictionary.getEntityClass(entityName); - if (entityClass == null || !dictionary.isRoot(entityClass)) { - throw new InvalidCollectionException(entityName); - } - - PersistentResource record = PersistentResource.loadRecord(entityClass, id, state.getRequestScope()); - state.setState(new RecordState(record)); - } - - @Override - public void handle(StateContext state, RootCollectionRelationshipContext ctx) { - EntityDictionary dictionary = state.getRequestScope().getDictionary(); - String entityName = ctx.entity().term().getText(); - String id = ctx.entity().id().getText(); - Class entityClass = dictionary.getEntityClass(entityName); - if (entityClass == null || !dictionary.isRoot(entityClass)) { - throw new InvalidCollectionException(entityName); - } - - PersistentResource record = PersistentResource.loadRecord(entityClass, id, state.getRequestScope()); - - String relationName = ctx.relationship().term().getText(); - try { - record.getRelationCheckedFiltered(relationName); - } catch (InvalidAttributeException e) { - throw new InvalidCollectionException(relationName); - } - - state.setState(new RelationshipTerminalState(record, relationName)); - } - - @Override - public String toString() { - return this.getClass().getName(); - } -} 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 deleted file mode 100644 index e417ddfa20..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/resources/JsonApiEndpoint.java +++ /dev/null @@ -1,132 +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.resources; - -import com.yahoo.elide.Elide; -import com.yahoo.elide.ElideResponse; -import com.yahoo.elide.annotation.PATCH; - -import java.util.function.Function; - -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; -import javax.ws.rs.Consumes; -import javax.ws.rs.DELETE; -import javax.ws.rs.GET; -import javax.ws.rs.HeaderParam; -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.PathParam; -import javax.ws.rs.Produces; -import javax.ws.rs.core.Context; -import javax.ws.rs.core.MultivaluedMap; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.SecurityContext; -import javax.ws.rs.core.UriInfo; - -/** - * Default endpoint/servlet for using Elide and JSONAPI. - */ -@Singleton -@Produces("application/vnd.api+json") -@Consumes("application/vnd.api+json") -@Path("/") -public class JsonApiEndpoint { - protected final Elide elide; - protected final Function getUser; - - @Inject - public JsonApiEndpoint(@Named("elide") Elide elide, - @Named("elideUserExtractionFunction") DefaultOpaqueUserFunction getUser) { - this.elide = elide; - this.getUser = getUser == null ? v -> null : getUser; - } - - /** - * Create handler. - * - * @param path request path - * @param securityContext security context - * @param jsonapiDocument post data as jsonapi document - * @return response - */ - @POST - @Path("{path:.*}") - public Response post( - @PathParam("path") String path, - @Context SecurityContext securityContext, - String jsonapiDocument) { - return build(elide.post(path, jsonapiDocument, getUser.apply(securityContext))); - } - - /** - * Read handler. - * - * @param path request path - * @param uriInfo URI info - * @param securityContext security context - * @return response - */ - @GET - @Path("{path:.*}") - public Response get( - @PathParam("path") String path, - @Context UriInfo uriInfo, - @Context SecurityContext securityContext) { - MultivaluedMap queryParams = uriInfo.getQueryParameters(); - return build(elide.get(path, queryParams, getUser.apply(securityContext))); - } - - /** - * Update handler. - * - * @param contentType document MIME type - * @param accept response MIME type - * @param path request path - * @param securityContext security context - * @param jsonapiDocument patch data as jsonapi document - * @return response - */ - @PATCH - @Path("{path:.*}") - public Response patch( - @HeaderParam("Content-Type") String contentType, - @HeaderParam("accept") String accept, - @PathParam("path") String path, - @Context SecurityContext securityContext, - String jsonapiDocument) { - return build(elide.patch(contentType, accept, path, jsonapiDocument, getUser.apply(securityContext))); - } - - /** - * Delete handler. - * - * @param path request path - * @param securityContext security context - * @param jsonApiDocument DELETE document - * @return response - */ - @DELETE - @Path("{path:.*}") - public Response delete( - @PathParam("path") String path, - @Context SecurityContext securityContext, - String jsonApiDocument) { - return build(elide.delete(path, jsonApiDocument, getUser.apply(securityContext))); - } - - private static Response build(ElideResponse response) { - return Response.status(response.getResponseCode()).entity(response.getBody()).build(); - } - - /** - * Placeholder for injection frameworks. - */ - public interface DefaultOpaqueUserFunction extends Function { - // Empty - } -} 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 deleted file mode 100644 index 5a4d68fb85..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/FilterExpressionCheck.java +++ /dev/null @@ -1,70 +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; - -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.filter.Predicate; -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.parsers.expression.FilterExpressionCheckEvaluationVisitor; -import com.yahoo.elide.security.checks.InlineCheck; - -import java.util.Optional; - -/** - * Check for FilterExpression. This is a super class for user defined FilterExpression check. The subclass should - * override getFilterExpression function and return a FilterExpression which will be passed down to datastore. - * @param Type of class - */ -public abstract class FilterExpressionCheck extends InlineCheck { - - /** - * Returns a FilterExpression from FilterExpressionCheck. - * @param requestScope Request scope object - * @return FilterExpression for FilterExpressionCheck. - */ - public abstract FilterExpression getFilterExpression(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. - * @param object object returned from datastore - * @param requestScope Request scope object - * @param changeSpec Summary of modifications - * @return true if the object pass evaluation against FilterExpression. - */ - @Override - public final boolean ok(T object, RequestScope requestScope, Optional changeSpec) { - FilterExpression filterExpression = getFilterExpression(requestScope); - return filterExpression.accept(new FilterExpressionCheckEvaluationVisitor(object, this, requestScope)); - } - - /** - * - * @param object object returned from datastore - * @param predicate A predicate from filterExpressionCheck - * @param requestScope Request scope object - * @return true if the object pass evaluation against Predicate. - */ - public boolean applyPredicateToObject(T object, Predicate predicate, RequestScope requestScope) { - Predicate.PathElement path = predicate.getPath().get(predicate.getPath().size() - 1); - Class type = path.getType(); - String fieldName = path.getFieldName(); - if (fieldName == null) { - return false; - } - EntityDictionary dictionary = ((com.yahoo.elide.core.RequestScope) requestScope).getDictionary(); - java.util.function.Predicate fn = predicate.getOperator() - .contextualize(fieldName, predicate.getValues(), dictionary); - return fn.test(object); - } -} 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 deleted file mode 100644 index 5ae695641a..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/PermissionExecutor.java +++ /dev/null @@ -1,143 +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; - -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.security.permissions.ExpressionResult; - -import org.antlr.v4.runtime.tree.ParseTree; - -import java.lang.annotation.Annotation; -import java.util.Optional; -import java.util.function.BiFunction; -import java.util.function.Function; - -/** - * Interface describing classes responsible for managing the life-cycle and execution of checks. - * - * Checks are expected to throw exceptions upon failures. - */ -public interface PermissionExecutor { - /** - * Check permission on class. - * - * @param type parameter - * @param annotationClass annotation class - * @param resource resource - * @see com.yahoo.elide.annotation.CreatePermission - * @see com.yahoo.elide.annotation.ReadPermission - * @see com.yahoo.elide.annotation.UpdatePermission - * @see com.yahoo.elide.annotation.DeletePermission - */ - ExpressionResult checkPermission(Class annotationClass, PersistentResource resource); - - /** - * Check permission on class. - * - * @param type parameter - * @param annotationClass annotation class - * @param resource resource - * @param changeSpec ChangeSpec - * @see com.yahoo.elide.annotation.CreatePermission - * @see com.yahoo.elide.annotation.ReadPermission - * @see com.yahoo.elide.annotation.UpdatePermission - * @see com.yahoo.elide.annotation.DeletePermission - */ - ExpressionResult checkPermission(Class annotationClass, - PersistentResource resource, - ChangeSpec changeSpec); - - /** - * Check for permissions on a specific field. - * - * @param type parameter - * @param resource resource - * @param changeSpec changepsec - * @param annotationClass annotation class - * @param field field to check - */ - ExpressionResult checkSpecificFieldPermissions(PersistentResource resource, - ChangeSpec changeSpec, - Class annotationClass, - String field); - - /** - * Check for permissions on a specific field deferring all checks. - * - * @param type parameter - * @param resource resource - * @param changeSpec changepsec - * @param annotationClass annotation class - * @param field field to check - */ - ExpressionResult checkSpecificFieldPermissionsDeferred(PersistentResource resource, - ChangeSpec changeSpec, - Class annotationClass, - String field); - - /** - * Check strictly user permissions on a specific field and entity. - * - * @param type parameter - * @param resource Resource - * @param annotationClass Annotation class - * @param field Field - */ - ExpressionResult checkUserPermissions(PersistentResource resource, - Class annotationClass, - String field); - - /** - * Check strictly user permissions on an entity. - * - * @param type parameter - * @param resourceClass Resource class - * @param annotationClass Annotation class - */ - ExpressionResult checkUserPermissions(Class resourceClass, Class annotationClass); - - Optional getReadPermissionFilter(Class resourceClass); - - /** - * Execute commmit checks. - */ - void executeCommitChecks(); - - /** - * Method to build criterion check. - * - * @param permissions Permissions to visit - * @param criterionNegater Function to apply negation to a criterion - * @param andCriterionJoiner Function to combine criteria with an and condition - * @param orCriterionJoiner Function to combine criteria with an or condition - * @param type parameter - * @return Built criterion if possible else null. Default always returns null. - */ - default T getCriterion(ParseTree permissions, - Function criterionNegater, - BiFunction andCriterionJoiner, - BiFunction orCriterionJoiner) { - return null; - } - - default boolean shouldShortCircuitPermissionChecks(Class annotationClass, - Class resourceClass, String field) { - return false; - } - - default String printCheckStats() { - return null; - } - - /** - * Whether or not the permission executor will return verbose logging to the requesting user in the response. - * - * @return True if verbose, false otherwise. - */ - default boolean isVerbose() { - return false; - } -} 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 deleted file mode 100644 index 2229e1ace5..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/executors/ActivePermissionExecutor.java +++ /dev/null @@ -1,416 +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.executors; - -import static com.yahoo.elide.security.permissions.ExpressionResult.DEFERRED; -import static com.yahoo.elide.security.permissions.ExpressionResult.FAIL; -import static com.yahoo.elide.security.permissions.ExpressionResult.PASS; - -import com.yahoo.elide.annotation.DeletePermission; -import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.annotation.SharePermission; -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.parsers.expression.CriterionExpressionVisitor; -import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.PermissionExecutor; -import com.yahoo.elide.security.PersistentResource; -import com.yahoo.elide.security.SecurityMode; -import com.yahoo.elide.security.permissions.ExpressionResult; -import com.yahoo.elide.security.permissions.ExpressionResultCache; -import com.yahoo.elide.security.permissions.PermissionExpressionBuilder; -import com.yahoo.elide.security.permissions.PermissionExpressionBuilder.Expressions; -import com.yahoo.elide.security.permissions.expressions.Expression; - -import org.antlr.v4.runtime.tree.ParseTree; -import org.apache.commons.lang3.tuple.Triple; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -import java.lang.annotation.Annotation; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Optional; -import java.util.Queue; -import java.util.Set; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.function.BiFunction; -import java.util.function.Function; - -/** - * Default permission executor. - * This executor executes all security checks as outlined in the documentation. - */ -@Slf4j -public class ActivePermissionExecutor implements PermissionExecutor { - private final Queue commitCheckQueue = new LinkedBlockingQueue<>(); - - private final RequestScope requestScope; - private final PermissionExpressionBuilder expressionBuilder; - private final Set, Class, String>> expressionResultShortCircuit; - private final Map, Class, String>, ExpressionResult> userPermissionCheckCache; - private final Map checkStats; - - /** - * Constructor. - * - * @param requestScope Request scope. - */ - public ActivePermissionExecutor(final com.yahoo.elide.core.RequestScope requestScope) { - ExpressionResultCache cache = new ExpressionResultCache(); - - this.requestScope = requestScope; - this.expressionBuilder = new PermissionExpressionBuilder(cache, requestScope.getDictionary()); - userPermissionCheckCache = new HashMap<>(); - expressionResultShortCircuit = new HashSet<>(); - checkStats = new HashMap<>(); - } - - /** - * Check permission on class. - * - * @param type parameter - * @param annotationClass annotation class - * @param resource resource - * @see com.yahoo.elide.annotation.CreatePermission - * @see com.yahoo.elide.annotation.ReadPermission - * @see com.yahoo.elide.annotation.UpdatePermission - * @see com.yahoo.elide.annotation.DeletePermission - */ - @Override - public ExpressionResult checkPermission( - Class annotationClass, PersistentResource resource) { - return checkPermission(annotationClass, resource, null); - } - - /** - * Check permission on class. - * - * @param annotationClass annotation class - * @param resource resource - * @param changeSpec ChangeSpec - * @param type parameter - * @see com.yahoo.elide.annotation.CreatePermission - * @see com.yahoo.elide.annotation.ReadPermission - * @see com.yahoo.elide.annotation.UpdatePermission - * @see com.yahoo.elide.annotation.DeletePermission - */ - @Override - public ExpressionResult checkPermission(Class annotationClass, - PersistentResource resource, - ChangeSpec changeSpec) { - if (requestScope.getSecurityMode() == SecurityMode.SECURITY_INACTIVE) { - return ExpressionResult.PASS; // Bypass - } - - Expressions expressions; - if (SharePermission.class == annotationClass) { - expressions = expressionBuilder.buildSharePermissionExpressions(resource); - } else { - ExpressionResult expressionResult = this.checkUserPermissions(resource.getResourceClass(), annotationClass); - if (expressionResult == PASS) { - return expressionResult; - } - expressions = expressionBuilder.buildAnyFieldExpressions(resource, annotationClass, changeSpec); - } - return executeExpressions(expressions, annotationClass); - } - - /** - * Check for permissions on a specific field. - * - * @param type parameter - * @param resource resource - * @param changeSpec changepsec - * @param annotationClass annotation class - * @param field field to check - */ - @Override - public ExpressionResult checkSpecificFieldPermissions(PersistentResource resource, - ChangeSpec changeSpec, - Class annotationClass, - String field) { - if (requestScope.getSecurityMode() == SecurityMode.SECURITY_INACTIVE) { - return ExpressionResult.PASS; // Bypass - } - - ExpressionResult expressionResult = this.checkUserPermissions(resource, annotationClass, field); - if (expressionResult == PASS) { - return expressionResult; - } - - Expressions expressions = expressionBuilder.buildSpecificFieldExpressions( - resource, - annotationClass, - field, - changeSpec - ); - return executeExpressions(expressions, annotationClass); - } - - /** - * Check for permissions on a specific field deferring all checks. - * - * @param type parameter - * @param resource resource - * @param changeSpec changepsec - * @param annotationClass annotation class - * @param field field to check - */ - @Override - public ExpressionResult checkSpecificFieldPermissionsDeferred(PersistentResource resource, - ChangeSpec changeSpec, - Class annotationClass, - String field) { - if (requestScope.getSecurityMode() == SecurityMode.SECURITY_INACTIVE) { - return ExpressionResult.PASS; // Bypass - } - - ExpressionResult expressionResult = this.checkUserPermissions(resource, annotationClass, field); - if (expressionResult == PASS) { - return expressionResult; - } - - Expressions expressions = expressionBuilder.buildSpecificFieldExpressions( - resource, - annotationClass, - field, - changeSpec - ); - Expression commitExpression = expressions.getCommitExpression(); - if (commitExpression != null) { - commitCheckQueue.add(new QueuedCheck(commitExpression, annotationClass)); - } - return ExpressionResult.DEFERRED; - } - - /** - * Check strictly user permissions on a specific field and entity. - * - * @param type parameter - * @param resource Resource - * @param annotationClass Annotation class - * @param field Field - */ - @Override - public ExpressionResult checkUserPermissions(PersistentResource resource, - Class annotationClass, - String field) { - if (requestScope.getSecurityMode() == SecurityMode.SECURITY_INACTIVE) { - return ExpressionResult.PASS; // Bypass - } - - // If the user check has already been evaluated before, return the result directly and save the building cost - ExpressionResult expressionResult - = userPermissionCheckCache.get(Triple.of(annotationClass, resource.getResourceClass(), field)); - if (expressionResult != null) { - return expressionResult; - } - - Expressions expressions = expressionBuilder.buildUserCheckFieldExpressions(resource, annotationClass, field); - expressionResult = executeExpressions(expressions, annotationClass); - - userPermissionCheckCache.put(Triple.of(annotationClass, resource.getResourceClass(), field), expressionResult); - - if (expressionResult == PASS) { - expressionResultShortCircuit.add(Triple.of(annotationClass, resource.getResourceClass(), field)); - } - - return expressionResult; - } - - /** - * Check strictly user permissions on an entity. - * - * @param type parameter - * @param resourceClass Resource class - * @param annotationClass Annotation class - */ - @Override - public ExpressionResult checkUserPermissions(Class resourceClass, - Class annotationClass) { - if (requestScope.getSecurityMode() == SecurityMode.SECURITY_INACTIVE) { - return ExpressionResult.PASS; // Bypass - } - - // If the user check has already been evaluated before, return the result directly and save the building cost - ExpressionResult expressionResult - = userPermissionCheckCache.get(Triple.of(annotationClass, resourceClass, null)); - if (expressionResult != null) { - return expressionResult; - } - - Expressions expressions = expressionBuilder.buildUserCheckAnyExpression( - resourceClass, - annotationClass, - requestScope - ); - expressionResult = executeExpressions(expressions, annotationClass); - - userPermissionCheckCache.put(Triple.of(annotationClass, resourceClass, null), expressionResult); - - if (expressionResult == PASS) { - expressionResultShortCircuit.add(Triple.of(annotationClass, resourceClass, null)); - } - - return expressionResult; - } - - /** - * Get permission filter on an entity. - * - * @param resourceClass Resource class - */ - public Optional getReadPermissionFilter(Class resourceClass) { - if (requestScope.getSecurityMode() == SecurityMode.SECURITY_INACTIVE) { - return Optional.empty(); // Bypass - } - FilterExpression filterExpression = - expressionBuilder.buildAnyFieldFilterExpression(resourceClass, requestScope); - - return Optional.ofNullable(filterExpression); - } - - /** - * Execute commmit checks. - */ - @Override - public void executeCommitChecks() { - commitCheckQueue.forEach((expr) -> { - Expression expression = expr.getExpression(); - ExpressionResult result = expression.evaluate(); - if (result == FAIL) { - ForbiddenAccessException e = new ForbiddenAccessException(expr.getAnnotationClass().getSimpleName(), - expression); - log.trace("{}", e.getLoggedMessage()); - throw e; - } - }); - commitCheckQueue.clear(); - } - - /** - * Build criterion check. - * - * @param permissions Permissions to visit - * @param criterionNegater Function to apply negation to a criterion - * @param andCriterionJoiner Function to combine criteria with an and condition - * @param orCriterionJoiner Function to combine criteria with an or condition - * @param type parameter - * @return - */ - @Override - public T getCriterion(final ParseTree permissions, - final Function criterionNegater, - final BiFunction andCriterionJoiner, - final BiFunction orCriterionJoiner) { - if (permissions == null) { - return null; - } - - CriterionExpressionVisitor visitor = new CriterionExpressionVisitor<>( - requestScope, - requestScope.getDictionary(), - criterionNegater, - orCriterionJoiner, - andCriterionJoiner - ); - - return visitor.visit(permissions); - } - - @Override - public boolean shouldShortCircuitPermissionChecks(Class annotationClass, - Class resourceClass, String field) { - return expressionResultShortCircuit.contains(Triple.of(annotationClass, resourceClass, field)); - } - - /** - * Execute expressions. - * - * @param expressions expressions to execute - */ - private ExpressionResult executeExpressions(final Expressions expressions, - final Class annotationClass) { - Expression expression = expressions.getOperationExpression(); - ExpressionResult result = expression.evaluate(); - - // Record the check - Long checkOccurrences = checkStats.getOrDefault(expression.toString(), 0L) + 1; - checkStats.put(expression.toString(), checkOccurrences); - - if (result == DEFERRED) { - Expression commitExpression = expressions.getCommitExpression(); - if (commitExpression != null) { - if (isInlineOnlyCheck(annotationClass)) { - // Force evaluation of checks that can only be executed inline. - result = commitExpression.evaluate(); - if (result == FAIL) { - ForbiddenAccessException e = new ForbiddenAccessException( - annotationClass.getSimpleName(), - commitExpression); - log.trace("{}", e.getLoggedMessage()); - throw e; - } - } else { - commitCheckQueue.add(new QueuedCheck(commitExpression, annotationClass)); - } - } - return DEFERRED; - } else if (result == FAIL) { - ForbiddenAccessException e = new ForbiddenAccessException(annotationClass.getSimpleName(), expression); - log.trace("{}", e.getLoggedMessage()); - throw e; - } - - return result; - } - - /** - * Check whether or not this check can only be run inline or not. - * - * @param annotationClass annotation class - * @return True if check can only be run inline, false otherwise. - */ - private boolean isInlineOnlyCheck(final Class annotationClass) { - return ReadPermission.class.isAssignableFrom(annotationClass) - || DeletePermission.class.isAssignableFrom(annotationClass); - } - - /** - * Information container about queued checks. - */ - @AllArgsConstructor - private static class QueuedCheck { - @Getter - private final Expression expression; - @Getter private final Class annotationClass; - } - - /** - * Print the permission check statistics - * @return the permission check statistics - */ - @Override - public String printCheckStats() { - StringBuilder sb = new StringBuilder("Permission Check Statistics:\n"); - checkStats.entrySet().stream() - .sorted(Map.Entry.comparingByValue()) - .forEachOrdered(e -> sb.append(e.getKey() + ": " + e.getValue() + "\n")); - String stats = sb.toString(); - log.trace(stats); - return stats; - } - - @Override - public boolean isVerbose() { - return requestScope.getSecurityMode() == SecurityMode.SECURITY_ACTIVE_VERBOSE; - } -} 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 deleted file mode 100644 index 0242e6c4e7..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/executors/BypassPermissionExecutor.java +++ /dev/null @@ -1,67 +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.executors; - -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; -import java.util.Optional; - -/** - * Permission executor intended to bypass all security checks. I.e. this is effectively a no-op. - */ -public class BypassPermissionExecutor implements PermissionExecutor { - @Override - public ExpressionResult checkPermission(Class annotationClass, - PersistentResource resource) { - return ExpressionResult.PASS; - } - - @Override - public ExpressionResult checkPermission(Class annotationClass, - PersistentResource resource, - ChangeSpec changeSpec) { - return ExpressionResult.PASS; - } - - @Override - public ExpressionResult checkSpecificFieldPermissions( - PersistentResource resource, ChangeSpec changeSpec, Class annotationClass, String field) { - return ExpressionResult.PASS; - } - - @Override - public ExpressionResult checkSpecificFieldPermissionsDeferred( - PersistentResource resource, ChangeSpec changeSpec, Class annotationClass, String field) { - return ExpressionResult.PASS; - } - - @Override - public ExpressionResult checkUserPermissions( - PersistentResource resource, Class annotationClass, String field) { - return ExpressionResult.PASS; - } - - @Override - public ExpressionResult checkUserPermissions(Class resourceClass, - Class annotationClass) { - return ExpressionResult.PASS; - } - - @Override - public Optional getReadPermissionFilter(Class resourceClass) { - return Optional.empty(); - } - - @Override - public void executeCommitChecks() { - - } -} 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 deleted file mode 100644 index e716e77c3d..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionCondition.java +++ /dev/null @@ -1,127 +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.permissions; - -import com.yahoo.elide.annotation.CreatePermission; -import com.yahoo.elide.annotation.DeletePermission; -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; -import lombok.Getter; - -import java.lang.annotation.Annotation; -import java.util.Optional; - -/** - * Describes the state when a permission is evaluated. - */ -public class PermissionCondition { - @Getter final Class permission; - @Getter final Class entityClass; - @Getter final Optional resource; - @Getter final Optional changes; - @Getter final Optional field; - - /** - * This function attempts to create the appropriate PermissionCondition based on parameters that may or may - * not be null. This is a temporary workaround given that the caller functions duplicate data in their - * signatures and pass nulls. The calling code needs to be cleaned up - and then this function can be disposed of. - * @param permission - * @param resource - * @param field - * @param changes - * @return - */ - public static PermissionCondition create( - Class permission, - PersistentResource resource, - String field, - ChangeSpec changes - ) { - if (resource != null) { - if (changes != null) { - /* Tests do this - not sure if this is real */ - if (field != null && changes.getFieldName() == null) { - return new PermissionCondition(permission, resource, field); - } - return new PermissionCondition(permission, resource, changes); - } else if (field == null) { - return new PermissionCondition(permission, resource); - } else { - return new PermissionCondition(permission, resource, field); - } - } - throw new IllegalArgumentException("Resource cannot be null"); - } - - PermissionCondition(Class permission, PersistentResource resource) { - this.permission = permission; - this.resource = Optional.of(resource); - this.entityClass = resource.getResourceClass(); - this.changes = Optional.empty(); - this.field = Optional.empty(); - } - - PermissionCondition(Class permission, PersistentResource resource, ChangeSpec changes) { - this.permission = permission; - this.resource = Optional.of(resource); - this.entityClass = resource.getResourceClass(); - this.changes = Optional.of(changes); - this.field = Optional.ofNullable(changes.getFieldName()); - } - - PermissionCondition(Class permission, Class entityClass) { - this.permission = permission; - this.resource = Optional.empty(); - this.entityClass = entityClass; - this.changes = Optional.empty(); - this.field = Optional.empty(); - } - - PermissionCondition(Class permission, PersistentResource resource, String field) { - this.permission = permission; - this.resource = Optional.of(resource); - this.entityClass = resource.getResourceClass(); - this.changes = Optional.empty(); - this.field = Optional.of(field); - } - - @Override - public String toString() { - Object entity = resource.isPresent() ? resource.get() : entityClass; - - String withChanges = changes.isPresent() ? String.format("WITH CHANGES %s", changes.get()) : ""; - String withField = field.isPresent() ? String.format("WITH FIELD %s", field.get()) : ""; - - String withClause = withChanges.isEmpty() ? withField : withChanges; - - return String.format( - "%s PERMISSION WAS INVOKED ON %s %s", - permission2text(permission), - entity, - withClause); - } - - private static String permission2text(Class permission) { - if (permission.equals(ReadPermission.class)) { - return "READ"; - } else if (permission.equals(UpdatePermission.class)) { - return "UPDATE"; - } else if (permission.equals(DeletePermission.class)) { - return "DELETE"; - } else if (permission.equals(CreatePermission.class)) { - return "CREATE"; - } else if (permission.equals(SharePermission.class)) { - return "SHARE"; - } else { - throw new IllegalArgumentException("Invalid annotation type"); - } - - } -} 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 deleted file mode 100644 index cfacd5946e..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/PermissionExpressionBuilder.java +++ /dev/null @@ -1,373 +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.permissions; - -import static com.yahoo.elide.parsers.expression.PermissionToFilterExpressionVisitor.FALSE_USER_CHECK_EXPRESSION; -import static com.yahoo.elide.parsers.expression.PermissionToFilterExpressionVisitor.NO_EVALUATION_EXPRESSION; -import static com.yahoo.elide.security.permissions.expressions.Expression.Results.FAILURE; - -import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.annotation.SharePermission; -import com.yahoo.elide.core.CheckInstantiator; -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.filter.expression.OrFilterExpression; -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.RequestScope; -import com.yahoo.elide.security.checks.Check; -import com.yahoo.elide.security.permissions.expressions.AnyFieldExpression; -import com.yahoo.elide.security.permissions.expressions.DeferredCheckExpression; -import com.yahoo.elide.security.permissions.expressions.Expression; -import com.yahoo.elide.security.permissions.expressions.ImmediateCheckExpression; -import com.yahoo.elide.security.permissions.expressions.OrExpression; -import com.yahoo.elide.security.permissions.expressions.SharePermissionExpression; -import com.yahoo.elide.security.permissions.expressions.SpecificFieldExpression; -import com.yahoo.elide.security.permissions.expressions.UserCheckOnlyExpression; - -import org.antlr.v4.runtime.tree.ParseTree; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.lang.annotation.Annotation; -import java.util.List; -import java.util.function.Function; - -/** - * Expression builder to parse annotations and express the result as the Expression AST. - */ -public class PermissionExpressionBuilder implements CheckInstantiator { - private final EntityDictionary entityDictionary; - private final ExpressionResultCache cache; - - private static final Expressions SUCCESSFUL_EXPRESSIONS = new Expressions( - OrExpression.SUCCESSFUL_EXPRESSION, - OrExpression.SUCCESSFUL_EXPRESSION - ); - - /** - * Constructor. - * - * @param cache Cache - * @param dictionary EntityDictionary - */ - public PermissionExpressionBuilder(ExpressionResultCache cache, EntityDictionary dictionary) { - this.cache = cache; - this.entityDictionary = dictionary; - } - - /** - * Build an expression that checks a specific field. - * - * @param resource Resource - * @param annotationClass Annotation class - * @param field Field - * @param changeSpec Change spec - * @param Type parameter - * @return Commit and operation expressions - */ - public Expressions buildSpecificFieldExpressions(final PersistentResource resource, - final Class annotationClass, - final String field, - final ChangeSpec changeSpec) { - - Class resourceClass = resource.getResourceClass(); - if (!entityDictionary.entityHasChecksForPermission(resourceClass, annotationClass)) { - return SUCCESSFUL_EXPRESSIONS; - } - - final Function deferredCheckFn = getDeferredExpressionFor(resource, changeSpec); - final Function immediateCheckFn = getImmediateExpressionFor(resource, changeSpec); - - final Function, Expression> buildExpressionFn = - (checkFn) -> buildSpecificFieldExpression( - PermissionCondition.create( - annotationClass, resource, field, changeSpec - ), - checkFn - ); - - return new Expressions( - buildExpressionFn.apply(deferredCheckFn), - buildExpressionFn.apply(immediateCheckFn) - ); - - } - - /** - * Build an expression that checks share permissions on a bean. - * - * @param resource Resource - * @return Commit and operation expressions - */ - public Expressions buildSharePermissionExpressions(final PersistentResource resource) { - - PermissionCondition condition = new PermissionCondition(SharePermission.class, resource); - - Class resourceClass = resource.getResourceClass(); - if (!entityDictionary.entityHasChecksForPermission(resourceClass, SharePermission.class)) { - SharePermissionExpression unshared = new SharePermissionExpression(condition); - return new Expressions(unshared, unshared); - } - - final Function deferredCheckFn = getDeferredExpressionFor(resource, null); - final Function immediateCheckFn = getImmediateExpressionFor(resource, null); - - final Function, Expression> expressionFunction = - (checkFn) -> { - ParseTree classPermissions = entityDictionary.getPermissionsForClass(resourceClass, - SharePermission.class); - Expression entityExpression = expressionFromParseTree(classPermissions, checkFn); - return new SharePermissionExpression(condition, entityExpression); - }; - - return new Expressions( - expressionFunction.apply(deferredCheckFn), - expressionFunction.apply(immediateCheckFn) - ); - } - - /** - * Build an expression that checks any field on a bean. - * - * @param resource Resource - * @param annotationClass annotation class - * @param changeSpec change spec - * @param type parameter - * @return Commit and operation expressions - */ - public Expressions buildAnyFieldExpressions(final PersistentResource resource, - final Class annotationClass, - final ChangeSpec changeSpec) { - - - Class resourceClass = resource.getResourceClass(); - if (!entityDictionary.entityHasChecksForPermission(resourceClass, annotationClass)) { - return SUCCESSFUL_EXPRESSIONS; - } - - final Function deferredCheckFn = getDeferredExpressionFor(resource, changeSpec); - final Function immediateCheckFn = getImmediateExpressionFor(resource, changeSpec); - - final Function, Expression> expressionFunction = - (checkFn) -> buildAnyFieldExpression( - PermissionCondition.create( - annotationClass, - resource, - (String) null, - changeSpec), - checkFn - ); - - return new Expressions( - expressionFunction.apply(deferredCheckFn), - expressionFunction.apply(immediateCheckFn) - ); - } - - /** - * Build an expression that strictly evaluates UserCheck's and ignores other checks for a specific field. - *

- * NOTE: This method returns _NO_ commit checks. - * - * @param resource Resource - * @param annotationClass Annotation class - * @param field Field to check (if null only check entity-level) - * @param type parameter - * @return User check expression to evaluate - */ - public Expressions buildUserCheckFieldExpressions(final PersistentResource resource, - final Class annotationClass, - final String field) { - Class resourceClass = resource.getResourceClass(); - if (!entityDictionary.entityHasChecksForPermission(resourceClass, annotationClass)) { - return SUCCESSFUL_EXPRESSIONS; - } - - final Function userCheckFn = - (check) -> new UserCheckOnlyExpression( - check, - resource, - resource.getRequestScope(), - (ChangeSpec) null, - cache - ); - - return new Expressions( - buildSpecificFieldExpression(new PermissionCondition(annotationClass, resource, field), userCheckFn), - null - ); - } - - /** - * Build an expression that strictly evaluates UserCheck's and ignores other checks for an entity. - *

- * NOTE: This method returns _NO_ commit checks. - * - * @param resourceClass Resource class - * @param annotationClass Annotation class - * @param requestScope Request scope - * @param type parameter - * @return User check expression to evaluate - */ - public Expressions buildUserCheckAnyExpression(final Class resourceClass, - final Class annotationClass, - final RequestScope requestScope) { - final Function userCheckFn = - (check) -> new UserCheckOnlyExpression( - check, - (PersistentResource) null, - requestScope, - (ChangeSpec) null, - cache - ); - - return new Expressions( - buildAnyFieldExpression(new PermissionCondition(annotationClass, resourceClass), userCheckFn), null); - } - - private Function getImmediateExpressionFor(PersistentResource resource, ChangeSpec changeSpec) { - return (check) -> new ImmediateCheckExpression( - check, - resource, - resource.getRequestScope(), - changeSpec, - cache - ); - } - - private Function getDeferredExpressionFor(PersistentResource resource, ChangeSpec changeSpec) { - return (check) -> new DeferredCheckExpression( - check, - resource, - resource.getRequestScope(), - changeSpec, - cache - ); - } - - /** - * Builder for specific field expressions. - * - * @param type parameter - * @param condition The condition which triggered this permission expression check - * @param checkFn Operation check function - * @return Expressions representing specific field - */ - private Expression buildSpecificFieldExpression(final PermissionCondition condition, - final Function checkFn) { - Class resourceClass = condition.getEntityClass(); - Class annotationClass = condition.getPermission(); - String field = condition.getField().isPresent() ? condition.getField().get() : null; - - ParseTree classPermissions = entityDictionary.getPermissionsForClass(resourceClass, annotationClass); - ParseTree fieldPermissions = entityDictionary.getPermissionsForField(resourceClass, field, annotationClass); - - return new SpecificFieldExpression(condition, - expressionFromParseTree(classPermissions, checkFn), - expressionFromParseTree(fieldPermissions, checkFn) - ); - } - - /** - * Build an expression representing any field on an entity. - * - * @param type parameter - * @param checkFn check function - * @return Expressions - */ - private Expression buildAnyFieldExpression(final PermissionCondition condition, - final Function checkFn) { - - - Class resourceClass = condition.getEntityClass(); - Class annotationClass = condition.getPermission(); - - ParseTree classPermissions = entityDictionary.getPermissionsForClass(resourceClass, annotationClass); - Expression entityExpression = expressionFromParseTree(classPermissions, checkFn); - - OrExpression allFieldsExpression = new OrExpression(FAILURE, null); - List fields = entityDictionary.getAllFields(resourceClass); - for (String field : fields) { - ParseTree fieldPermissions = entityDictionary.getPermissionsForField(resourceClass, field, annotationClass); - Expression fieldExpression = expressionFromParseTree(fieldPermissions, checkFn); - - allFieldsExpression = new OrExpression(allFieldsExpression, fieldExpression); - } - - return new AnyFieldExpression(condition, entityExpression, allFieldsExpression); - } - - /** - * Build an expression representing any field on an entity. - * - * @param type parameter - * @param resourceClass Resource class - * @param requestScope requestScope - * @return Expressions - */ - public FilterExpression buildAnyFieldFilterExpression(Class resourceClass, - RequestScope requestScope) { - - Class annotationClass = ReadPermission.class; - ParseTree classPermissions = entityDictionary.getPermissionsForClass(resourceClass, annotationClass); - FilterExpression entityFilterExpression = filterExpressionFromParseTree(classPermissions, requestScope); - FilterExpression allFieldsFilterExpression = entityFilterExpression; - List fields = entityDictionary.getAllFields(resourceClass); - for (String field : fields) { - ParseTree fieldPermissions = entityDictionary.getPermissionsForField(resourceClass, field, annotationClass); - FilterExpression fieldExpression = filterExpressionFromParseTree(fieldPermissions, requestScope); - if (fieldExpression == null) { - if (entityFilterExpression == null) { - //If the class FilterExpression is also null, we should just return null to allow the field with - // null FE be able to see all records. - return null; - } - continue; - } - if (allFieldsFilterExpression == null) { - allFieldsFilterExpression = fieldExpression; - } else { - allFieldsFilterExpression = new OrFilterExpression(allFieldsFilterExpression, fieldExpression); - } - } - return allFieldsFilterExpression; - } - - private Expression expressionFromParseTree(ParseTree permissions, Function checkFn) { - if (permissions == null) { - return null; - } - - return new PermissionExpressionVisitor(entityDictionary, checkFn).visit(permissions); - } - - private FilterExpression filterExpressionFromParseTree(ParseTree permissions, RequestScope requestScope) { - if (permissions == null) { - return null; - } - FilterExpression permissionFilter = new PermissionToFilterExpressionVisitor(entityDictionary, - requestScope).visit(permissions); - //case where the permissions does not have ANY filterExpressionCheck - if (permissionFilter == NO_EVALUATION_EXPRESSION - || permissionFilter == FALSE_USER_CHECK_EXPRESSION) { - return null; - } - return permissionFilter; - } - - /** - * Structure containing operation-time and commit-time expressions. - */ - @AllArgsConstructor - public static class Expressions { - @Getter private final Expression operationExpression; - @Getter private final Expression commitExpression; - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/AndExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/AndExpression.java deleted file mode 100644 index d37a2c9e96..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/AndExpression.java +++ /dev/null @@ -1,62 +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.permissions.expressions; - -import com.yahoo.elide.security.permissions.ExpressionResult; - -import static com.yahoo.elide.security.permissions.ExpressionResult.DEFERRED; -import static com.yahoo.elide.security.permissions.ExpressionResult.FAIL; -import static com.yahoo.elide.security.permissions.ExpressionResult.PASS; - -/** - * Representation for an "And" expression. - */ -public class AndExpression implements Expression { - private final Expression left; - private final Expression right; - - /** - * Constructor. - * - * @param left Left expression - * @param right Right expression - */ - public AndExpression(final Expression left, final Expression right) { - this.left = left; - this.right = right; - } - - @Override - public ExpressionResult evaluate() { - ExpressionResult leftStatus = left.evaluate(); - - // Short-circuit - if (leftStatus == FAIL) { - return leftStatus; - } - - ExpressionResult rightStatus = (right == null) ? PASS : right.evaluate(); - - if (rightStatus == FAIL) { - return rightStatus; - } - - if (leftStatus == PASS && rightStatus == PASS) { - return PASS; - } - - return DEFERRED; - } - - @Override - public String toString() { - if (right == null) { - return String.format("%s", left); - - } - return String.format("(%s) AND (%s)", left, right); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/AnyFieldExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/AnyFieldExpression.java deleted file mode 100644 index 721dd2241f..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/AnyFieldExpression.java +++ /dev/null @@ -1,51 +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.permissions.expressions; - -import com.yahoo.elide.security.permissions.ExpressionResult; -import com.yahoo.elide.security.permissions.PermissionCondition; - -import static com.yahoo.elide.security.permissions.ExpressionResult.FAIL; -import static com.yahoo.elide.security.permissions.ExpressionResult.PASS; - -/** - * This check determines if an entity is accessible to the current user. - * - * An entity is considered to be accessible if there exists an annotation at _any_ level of the object that - * Grants access. This means that if access is permitted to any field of the object then the object - * is accessible, regardless of what any class or package level permissions would permit. - */ -public class AnyFieldExpression implements Expression { - private final Expression entityExpression; - private final Expression fieldExpression; - private final PermissionCondition condition; - - public AnyFieldExpression(final PermissionCondition condition, - final Expression entityExpression, - final OrExpression fieldExpression) { - this.condition = condition; - this.entityExpression = entityExpression; - this.fieldExpression = fieldExpression; - } - - @Override - public ExpressionResult evaluate() { - ExpressionResult fieldResult = fieldExpression.evaluate(); - - if (fieldResult != FAIL) { - return fieldResult; - } - - ExpressionResult entityResult = (entityExpression == null) ? PASS : entityExpression.evaluate(); - return entityResult; - } - - @Override - public String toString() { - return String.format("%s FOR EXPRESSION [FIELDS(%s) OR ENTITY(%s)]", - condition, fieldExpression, entityExpression); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/DeferredCheckExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/DeferredCheckExpression.java deleted file mode 100644 index 022a5305c0..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/DeferredCheckExpression.java +++ /dev/null @@ -1,50 +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.permissions.expressions; - -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.CommitCheck; -import com.yahoo.elide.security.permissions.ExpressionResult; -import com.yahoo.elide.security.permissions.ExpressionResultCache; -import lombok.extern.slf4j.Slf4j; - -import static com.yahoo.elide.security.permissions.ExpressionResult.DEFERRED; - -/** - * Expression for only executing operation checks and skipping commit checks. - */ -@Slf4j -public class DeferredCheckExpression extends ImmediateCheckExpression { - /** - * Constructor. - * - * @param check Check - * @param resource Persistent resource - * @param requestScope Request scope - * @param changeSpec Change spec - * @param cache Cache - */ - public DeferredCheckExpression(final Check check, - final PersistentResource resource, - final RequestScope requestScope, - final ChangeSpec changeSpec, - final ExpressionResultCache cache) { - super(check, resource, requestScope, changeSpec, cache); - } - - @Override - public ExpressionResult evaluate() { - if (check instanceof CommitCheck) { - result = DEFERRED; - log.debug("Deferring check: {}", check); - return result; - } - return super.evaluate(); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/Expression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/Expression.java deleted file mode 100644 index a961642fe4..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/Expression.java +++ /dev/null @@ -1,51 +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.permissions.expressions; - -import com.yahoo.elide.security.permissions.ExpressionResult; -import org.fusesource.jansi.Ansi; - -import static org.fusesource.jansi.Ansi.ansi; - -/** - * Interface describing an expression. - */ -public interface Expression { - /** - * Evaluate an expression. - * - * @return The result of the fully evaluated expression. - */ - ExpressionResult evaluate(); - - /** - * Static Expressions that return PASS or FAIL. - */ - public static class Results { - public static final Expression SUCCESS = new Expression() { - @Override - public ExpressionResult evaluate() { - return ExpressionResult.PASS; - } - - @Override - public String toString() { - return ansi().fg(Ansi.Color.GREEN).a("SUCCESS").reset().toString(); - } - }; - public static final Expression FAILURE = new Expression() { - @Override - public ExpressionResult evaluate() { - return ExpressionResult.FAIL; - } - - @Override - public String toString() { - return ansi().fg(Ansi.Color.RED).a("FAILURE").reset().toString(); - } - }; - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/ImmediateCheckExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/ImmediateCheckExpression.java deleted file mode 100644 index 3634ff9870..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/ImmediateCheckExpression.java +++ /dev/null @@ -1,107 +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.permissions.expressions; - -import com.yahoo.elide.core.EntityDictionary; -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.UserCheck; -import com.yahoo.elide.security.permissions.ExpressionResult; -import com.yahoo.elide.security.permissions.ExpressionResultCache; -import lombok.extern.slf4j.Slf4j; - -import java.util.Optional; - -import static com.yahoo.elide.security.permissions.ExpressionResult.FAIL; -import static com.yahoo.elide.security.permissions.ExpressionResult.PASS; -import static com.yahoo.elide.security.permissions.ExpressionResult.UNEVALUATED; - -/** - * Expression for executing all specified checks. - */ -@Slf4j -public class ImmediateCheckExpression implements Expression { - protected final Check check; - protected final PersistentResource resource; - protected final RequestScope requestScope; - protected final ExpressionResultCache cache; - protected ExpressionResult result; - - private final Optional changeSpec; - - /** - * Constructor. - * - * @param check The check to be evaluated by this expression - * @param resource The resource to pass to the check - * @param requestScope The requestScope to pass to the check - * @param changeSpec The changeSpec to pass to the check - * @param cache The cache of previous expression results - */ - public ImmediateCheckExpression(final Check check, - final PersistentResource resource, - final RequestScope requestScope, - final ChangeSpec changeSpec, - final ExpressionResultCache cache) { - this.check = check; - this.requestScope = requestScope; - this.changeSpec = Optional.ofNullable(changeSpec); - this.cache = cache; - this.result = UNEVALUATED; - - // UserCheck does not use resource - this.resource = (check instanceof UserCheck) ? null : resource; - } - - @Override - public ExpressionResult evaluate() { - log.trace("Evaluating check: {}", check); - - // If we have a valid change spec, do not cache the result or look for a cached result. - if (changeSpec.isPresent()) { - log.trace("-- Check has changespec: {}", changeSpec); - ExpressionResult result = computeCheck(); - log.trace("-- Check returned with result: {}", result); - return result; - } - - // Otherwise, search the cache and use value if found. Otherwise, evaluate and add it to the cache. - log.trace("-- Check does NOT have changespec"); - Class checkClass = check.getClass(); - - final ExpressionResult result; - if (cache.hasStoredResultFor(checkClass, resource)) { - result = cache.getResultFor(checkClass, resource); - } else { - result = computeCheck(); - cache.putResultFor(checkClass, resource, result); - log.trace("-- Check computed result: {}", result); - } - - log.trace("-- Check returned with result: {}", result); - - return result; - } - - /** - * Actually compute the result of the check without caching concerns. - * - * @return Expression result from the check. - */ - private ExpressionResult computeCheck() { - Object entity = (resource == null) ? null : resource.getObject(); - result = check.ok(entity, requestScope, changeSpec) ? PASS : FAIL; - return result; - } - - @Override - public String toString() { - EntityDictionary dictionary = ((com.yahoo.elide.core.RequestScope) requestScope).getDictionary(); - return String.format("(%s %s)", dictionary.getCheckIdentifier(check.getClass()), result); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/NotExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/NotExpression.java deleted file mode 100644 index 526a0eea9f..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/NotExpression.java +++ /dev/null @@ -1,50 +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.permissions.expressions; - - -import com.yahoo.elide.security.permissions.ExpressionResult; - -import static com.yahoo.elide.security.permissions.ExpressionResult.DEFERRED; -import static com.yahoo.elide.security.permissions.ExpressionResult.FAIL; -import static com.yahoo.elide.security.permissions.ExpressionResult.PASS; - -/** - * Representation of a "not" expression. - */ -public class NotExpression implements Expression { - private final Expression logical; - - /** - * Constructor. - * - * @param logical Unary expression - */ - public NotExpression(final Expression logical) { - this.logical = logical; - } - - - @Override - public ExpressionResult evaluate() { - ExpressionResult result = logical.evaluate(); - - if (result == FAIL) { - return PASS; - } - - if (result == PASS) { - return FAIL; - } - - return DEFERRED; - } - - @Override - public String toString() { - return String.format("NOT (%s)", logical); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/OrExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/OrExpression.java deleted file mode 100644 index 5f266e7f62..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/OrExpression.java +++ /dev/null @@ -1,63 +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.permissions.expressions; - -import com.yahoo.elide.security.permissions.ExpressionResult; - -import static com.yahoo.elide.security.permissions.ExpressionResult.DEFERRED; -import static com.yahoo.elide.security.permissions.ExpressionResult.FAIL; -import static com.yahoo.elide.security.permissions.ExpressionResult.PASS; - -/** - * Representation of an "or" expression. - */ -public class OrExpression implements Expression { - private final Expression left; - private final Expression right; - - public static final OrExpression SUCCESSFUL_EXPRESSION = new OrExpression(Results.SUCCESS, null); - - /** - * Constructor. - * - * @param left Left expression - * @param right Right expression. - */ - public OrExpression(final Expression left, final Expression right) { - this.left = left; - this.right = right; - } - - @Override - public ExpressionResult evaluate() { - ExpressionResult leftResult = left.evaluate(); - - // Short-circuit - if (leftResult == PASS) { - return PASS; - } - - ExpressionResult rightResult = (right == null) ? leftResult : right.evaluate(); - - if (leftResult == FAIL && rightResult == FAIL) { - return leftResult; - } - - if (rightResult == PASS) { - return PASS; - } - - return DEFERRED; - } - - @Override - public String toString() { - if (right == null || right.equals(Results.FAILURE)) { - return String.format("%s", left); - } - return String.format("(%s) OR (%s)", left, right); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/SharePermissionExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/SharePermissionExpression.java deleted file mode 100644 index 8a9a348698..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/SharePermissionExpression.java +++ /dev/null @@ -1,52 +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.permissions.expressions; - -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.security.PersistentResource; -import com.yahoo.elide.security.permissions.ExpressionResult; -import com.yahoo.elide.security.permissions.PermissionCondition; - -import java.util.Optional; - -import static com.yahoo.elide.security.permissions.ExpressionResult.FAIL; - -/** - * Determines whether a resource is shareable. - */ -public class SharePermissionExpression implements Expression { - private final Optional entityExpression; - private final PermissionCondition condition; - - public SharePermissionExpression(final PermissionCondition condition, - final Expression entityExpression) { - this.condition = condition; - this.entityExpression = Optional.of(entityExpression); - } - - public SharePermissionExpression(final PermissionCondition condition) { - this.condition = condition; - this.entityExpression = Optional.ofNullable(null); - } - - @Override - public ExpressionResult evaluate() { - PersistentResource resource = condition.getResource().get(); - EntityDictionary dictionary = ((com.yahoo.elide.core.PersistentResource) resource).getDictionary(); - - if (!dictionary.isShareable(resource.getResourceClass()) || !entityExpression.isPresent()) { - return FAIL; - } - - return entityExpression.get().evaluate(); - } - - @Override - public String toString() { - String entityText = entityExpression.isPresent() ? entityExpression.get().toString() : "NOT MARKED SHAREABLE"; - return String.format("%s FOR EXPRESSION [SHARE ENTITY(%s)]", condition, entityText); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/SpecificFieldExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/SpecificFieldExpression.java deleted file mode 100644 index b9e4fff219..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/SpecificFieldExpression.java +++ /dev/null @@ -1,65 +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.permissions.expressions; - -import com.yahoo.elide.security.permissions.ExpressionResult; -import com.yahoo.elide.security.permissions.PermissionCondition; -import lombok.Getter; - -import java.util.Optional; - -import static com.yahoo.elide.security.permissions.ExpressionResult.PASS; - -/** - * Expression for joining specific fields. - * - * That is, this evaluates security while giving precedence to the annotation on a particular field over - * the annotation at the entity- or package-level. - */ -public class SpecificFieldExpression implements Expression { - private final Expression entityExpression; - private final Optional fieldExpression; - @Getter private final PermissionCondition condition; - - public SpecificFieldExpression(final PermissionCondition condition, - final Expression entityExpression, - final Expression fieldExpression) { - this.condition = condition; - this.entityExpression = entityExpression; - this.fieldExpression = Optional.ofNullable(fieldExpression); - } - - @Override - public ExpressionResult evaluate() { - if (!fieldExpression.isPresent()) { - ExpressionResult entityResult = (entityExpression == null) ? PASS : entityExpression.evaluate(); - return entityResult; - } else { - ExpressionResult fieldResult = fieldExpression.get().evaluate(); - return fieldResult; - } - } - - - @Override - public String toString() { - if (entityExpression == null && !fieldExpression.isPresent()) { - return String.format("%s FOR EXPRESSION []", condition); - } - - if (!fieldExpression.isPresent()) { - return String.format( - "%s FOR EXPRESSION [ENTITY(%s)]", - condition, - entityExpression); - } - - return String.format( - "%s FOR EXPRESSION [FIELD(%s)]", - condition, - fieldExpression.get()); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/UserCheckOnlyExpression.java b/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/UserCheckOnlyExpression.java deleted file mode 100644 index 8a33a753f4..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/security/permissions/expressions/UserCheckOnlyExpression.java +++ /dev/null @@ -1,48 +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.permissions.expressions; - -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.UserCheck; -import com.yahoo.elide.security.permissions.ExpressionResult; -import com.yahoo.elide.security.permissions.ExpressionResultCache; - -import static com.yahoo.elide.security.permissions.ExpressionResult.DEFERRED; - -/** - * Special expression that only evaluates UserChecks. - */ -public class UserCheckOnlyExpression extends ImmediateCheckExpression { - - /** - * Constructor. - * - * @param check Check - * @param resource Persistent resource - * @param requestScope Request scope - * @param changeSpec ChangeSpec - * @param cache Cache - */ - public UserCheckOnlyExpression(final Check check, - final PersistentResource resource, - final RequestScope requestScope, - final ChangeSpec changeSpec, - final ExpressionResultCache cache) { - super(check, resource, requestScope, changeSpec, cache); - } - - @Override - public ExpressionResult evaluate() { - if (check instanceof UserCheck) { - return super.evaluate(); - } - result = DEFERRED; - return result; - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/utils/HeaderUtils.java b/elide-core/src/main/java/com/yahoo/elide/utils/HeaderUtils.java new file mode 100644 index 0000000000..b30b1f6c6b --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/utils/HeaderUtils.java @@ -0,0 +1,53 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.utils; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + + +/** + * Utility class which modifies request headers + */ +public class HeaderUtils { + + /** + * Resolve value of api version from request headers. + * @param headers HttpHeaders + * @return apiVersion + */ + + public static String resolveApiVersion(Map> headers) { + String apiVersion = NO_VERSION; + if (headers != null && headers.get("ApiVersion") != null) { + apiVersion = headers.get("ApiVersion").get(0); + } + return apiVersion; + } + /** + * Remove Authorization and Proxy Authorization headers from request headers. + * @param headers HttpHeaders + * @return requestHeaders + */ + public static Map> lowercaseAndRemoveAuthHeaders(Map> headers) { + // HTTP headers should be treated lowercase, but maybe not all libraries consider this + Map> requestHeaders = headers.entrySet().stream() + .collect(Collectors.toMap(entry -> entry.getKey().toLowerCase(Locale.ENGLISH), Map.Entry::getValue)); + + if (requestHeaders.get("authorization") != null) { + requestHeaders.remove("authorization"); + } + if (requestHeaders.get("proxy-authorization") != null) { + requestHeaders.remove("proxy-authorization"); + } + return requestHeaders; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/utils/ResourceUtils.java b/elide-core/src/main/java/com/yahoo/elide/utils/ResourceUtils.java new file mode 100644 index 0000000000..bb52787a4e --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/utils/ResourceUtils.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.utils; + +import java.net.URI; +import javax.ws.rs.core.UriInfo; + +/** + * Utility class which is shared by Resources/Controllers. + */ +public class ResourceUtils { + + /** + * Resolve value of base url from UriInfo. + * @param uriInfo UriInfo + * @return baseUrl + */ + public static String resolveBaseUrl(UriInfo uriInfo) { + URI uri = uriInfo.getBaseUri(); + StringBuilder str = new StringBuilder(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() != -1) { + str.append(":" + uri.getPort()); + } + return str.toString(); + } +} 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 deleted file mode 100644 index 9d7bb2e8e8..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/utils/coerce/CoerceUtil.java +++ /dev/null @@ -1,86 +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.utils.coerce; - -import com.yahoo.elide.core.exceptions.InvalidAttributeException; -import com.yahoo.elide.core.exceptions.InvalidValueException; -import com.yahoo.elide.utils.coerce.converters.EpochToDateConverter; -import com.yahoo.elide.utils.coerce.converters.FromMapConverter; -import com.yahoo.elide.utils.coerce.converters.ToEnumConverter; -import org.apache.commons.beanutils.BeanUtilsBean; -import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.beanutils.ConvertUtils; -import org.apache.commons.beanutils.ConvertUtilsBean; -import org.apache.commons.beanutils.Converter; -import org.apache.commons.lang3.ClassUtils; - -import java.util.Date; -import java.util.Map; - -/** - * Class for coercing a value to a target class. - */ -public class CoerceUtil { - - private static final ToEnumConverter TO_ENUM_CONVERTER = new ToEnumConverter(); - private static final FromMapConverter FROM_MAP_CONVERTER = new FromMapConverter(); - private static final EpochToDateConverter EPOCH_TO_DATE_CONVERTER = new EpochToDateConverter(); - - //static block for setup and registering new converters - static { - setup(); - } - - /** - * Convert value to target class. - * - * @param value value to convert - * @param cls class to convert to - * @return coerced value - */ - public static T coerce(Object value, Class cls) { - if (value == null || cls == null || cls.isAssignableFrom(value.getClass())) { - return (T) value; - } - - try { - return (T) ConvertUtils.convert(value, cls); - } catch (ConversionException | InvalidAttributeException | IllegalArgumentException e) { - throw new InvalidValueException(value, e.getMessage()); - } - } - - /** - * Perform CoerceUtil setup. - */ - private static void setup() { - BeanUtilsBean.setInstance(new BeanUtilsBean(new ConvertUtilsBean() { - { - // https://github.com/yahoo/elide/issues/260 - // enable throwing exceptions when conversion fails - register(true, false, 0); - } - - @Override - /* - * Overriding lookup to execute enum converter if target is enum - * or map convert if source is map - */ - public Converter lookup(Class sourceType, Class targetType) { - if (targetType.isEnum()) { - return TO_ENUM_CONVERTER; - } else if (Map.class.isAssignableFrom(sourceType)) { - return FROM_MAP_CONVERTER; - } else if ((String.class.isAssignableFrom(sourceType) || Long.class.isAssignableFrom(sourceType)) - && ClassUtils.isAssignable(targetType, Date.class)) { - return EPOCH_TO_DATE_CONVERTER; - } else { - return super.lookup(sourceType, targetType); - } - } - })); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/utils/coerce/converters/EpochToDateConverter.java b/elide-core/src/main/java/com/yahoo/elide/utils/coerce/converters/EpochToDateConverter.java deleted file mode 100644 index 2aa777d9c8..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/utils/coerce/converters/EpochToDateConverter.java +++ /dev/null @@ -1,55 +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.utils.coerce.converters; - -import com.yahoo.elide.core.exceptions.InvalidAttributeException; - -import org.apache.commons.beanutils.Converter; -import org.apache.commons.lang3.ClassUtils; - -import java.sql.Time; -import java.sql.Timestamp; -import java.util.Date; - -/** - * Convert epoch(in string or long) to Date - */ -public class EpochToDateConverter implements Converter { - - @Override - public T convert(Class cls, Object value) { - try { - if (ClassUtils.isAssignable(value.getClass(), String.class)) { - return stringToDate(cls, (String) value); - } else if (ClassUtils.isAssignable(value.getClass(), Long.class, true)) { - return longToDate(cls, (Long) value); - } else { - throw new UnsupportedOperationException(value.getClass().getSimpleName() + " is not a valid epoch"); - } - } catch (IndexOutOfBoundsException | ReflectiveOperationException - | UnsupportedOperationException | IllegalArgumentException e) { - throw new InvalidAttributeException("Unknown " + cls.getSimpleName() + " value " + value, e); - } - } - - private static T longToDate(Class cls, Long epoch) throws ReflectiveOperationException { - if (ClassUtils.isAssignable(cls, java.sql.Date.class)) { - return (T) new java.sql.Date(epoch); - } else if (ClassUtils.isAssignable(cls, Timestamp.class)) { - return (T) new Timestamp(epoch); - } else if (ClassUtils.isAssignable(cls, Time.class)) { - return (T) new Time(epoch); - } else if (ClassUtils.isAssignable(cls, Date.class)) { - return (T) new Date(epoch); - } else { - throw new UnsupportedOperationException("Cannot convert to " + cls.getSimpleName()); - } - } - - private static T stringToDate(Class cls, String epoch) throws ReflectiveOperationException { - return longToDate(cls, Long.parseLong(epoch)); - } -} diff --git a/elide-core/src/main/java/com/yahoo/elide/utils/coerce/converters/FromMapConverter.java b/elide-core/src/main/java/com/yahoo/elide/utils/coerce/converters/FromMapConverter.java deleted file mode 100644 index 4df99041e9..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/utils/coerce/converters/FromMapConverter.java +++ /dev/null @@ -1,28 +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.utils.coerce.converters; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.commons.beanutils.Converter; - -/** - * Uses Jackson to Convert from Map to target object. - */ -public class FromMapConverter implements Converter { - /** - * Convert value to Enum. - * - * @param cls class to convert to - * @param value value to convert - * @param object type - * @return converted object - */ - @Override - public T convert(Class cls, Object value) { - ObjectMapper mapper = new ObjectMapper(); - return mapper.convertValue(value, cls); - } -} diff --git a/elide-core/src/test/groovy/com/yahoo/elide/core/filter/PredicateTest.groovy b/elide-core/src/test/groovy/com/yahoo/elide/core/filter/PredicateTest.groovy deleted file mode 100644 index 3081415750..0000000000 --- a/elide-core/src/test/groovy/com/yahoo/elide/core/filter/PredicateTest.groovy +++ /dev/null @@ -1,223 +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.filter -import com.yahoo.elide.core.EntityDictionary -import com.yahoo.elide.core.exceptions.InvalidPredicateException -import com.yahoo.elide.core.filter.expression.PredicateExtractionVisitor -import com.yahoo.elide.core.filter.dialect.DefaultFilterDialect -import com.yahoo.elide.core.filter.dialect.ParseException -import example.Author -import example.Book -import org.testng.Assert -import org.testng.annotations.BeforeSuite -import org.testng.annotations.Test - -import javax.ws.rs.core.MultivaluedHashMap -import javax.ws.rs.core.MultivaluedMap - -import static org.mockito.Mockito.mock -import static org.mockito.Mockito.when -/** - * Predicate test class. - */ -public class PredicateTest { - private DefaultFilterDialect strategy - - @BeforeSuite - public void setup() { - EntityDictionary entityDictionary = mock(EntityDictionary.class) - when(entityDictionary.getJsonAliasFor(String.class)).thenReturn("string") - when(entityDictionary.getJsonAliasFor(Book.class)).thenReturn("type1") - when(entityDictionary.getJsonAliasFor(Author.class)).thenReturn("type2") - when(entityDictionary.getEntityClass("type1")).thenReturn(Book.class) - when(entityDictionary.getEntityClass("type2")).thenReturn(Author.class) - when(entityDictionary.getParameterizedType(Book.class, "field1")).thenReturn(String.class) - when(entityDictionary.getParameterizedType(Book.class, "field2")).thenReturn(String.class) - when(entityDictionary.getParameterizedType(Book.class, "intField")).thenReturn(Integer.class) - strategy = new DefaultFilterDialect(entityDictionary); - } - - private Map> parse(MultivaluedMap queryParams) { - PredicateExtractionVisitor visitor = new PredicateExtractionVisitor(); - - def expressionMap; - try { - expressionMap = strategy.parseTypedExpression("/type1", queryParams); - } catch (ParseException e) { - throw new InvalidPredicateException(e.getMessage()); - } - - def returnMap = new HashMap>(); - for (entry in expressionMap) { - def typeName = entry.key; - def expression = entry.value; - if (!returnMap.containsKey(typeName)) { - returnMap[typeName] = new HashSet(); - } - returnMap[typeName].addAll(expression.accept(visitor)); - } - return returnMap; - } - - @Test - public void testSingleFieldSingleValue() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.field1]", "abc") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - def predicate = predicates.get("type1").iterator().next() - Assert.assertEquals(predicate.getField(), "field1") - Assert.assertEquals(predicate.getOperator(), Operator.IN) - Assert.assertEquals(predicate.getValues(), ["abc"]) - } - - @Test - public void testSingleFieldMultipleValues() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.field1]", "abc,def") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - def predicate = predicates.get("type1").iterator().next() - Assert.assertEquals(predicate.getField(), "field1") - Assert.assertEquals(predicate.getOperator(), Operator.IN) - Assert.assertEquals(predicate.getValues(), ["abc", "def"]) - } - - @Test - public void testMultipleFieldMultipleValues() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.field1]", "abc,def") - queryParams.add("filter[type1.field2]", "def,jkl") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - for (Predicate predicate : predicates.get("type1")) { - switch (predicate.getField()) { - case "field1": - Assert.assertEquals(predicate.getOperator(), Operator.IN) - Assert.assertEquals(predicate.getValues(), ["abc", "def"]) - break - case "field2": - Assert.assertEquals(predicate.getOperator(), Operator.IN) - Assert.assertEquals(predicate.getValues(), ["def", "jkl"]) - break - default: - Assert.fail(predicate.toString() + " case not covered") - } - } - } - - @Test - public void testSingleFieldWithInOperator() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.field1][in]", "abc,def") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - def predicate = predicates.get("type1").iterator().next() - Assert.assertEquals(predicate.getField(), "field1") - Assert.assertEquals(predicate.getOperator(), Operator.IN) - Assert.assertEquals(predicate.getValues(), ["abc", "def"]) - } - - @Test - public void testSingleFieldWithNotOperator() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.field1][not]", "abc,def") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - def predicate = predicates.get("type1").iterator().next() - Assert.assertEquals(predicate.getField(), "field1") - Assert.assertEquals(predicate.getOperator(), Operator.NOT) - Assert.assertEquals(predicate.getValues(), ["abc", "def"]) - } - - @Test - public void testSingleFieldWithPrefixOperator() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.field1][prefix]", "abc") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - def predicate = predicates.get("type1").iterator().next() - Assert.assertEquals(predicate.getField(), "field1") - Assert.assertEquals(predicate.getOperator(), Operator.PREFIX) - Assert.assertEquals(predicate.getValues(), ["abc"]) - } - - @Test - public void testSingleFieldWithPostfixOperator() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.field1][postfix]", "abc,def") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - def predicate = predicates.get("type1").iterator().next() - Assert.assertEquals(predicate.getField(), "field1") - Assert.assertEquals(predicate.getOperator(), Operator.POSTFIX) - Assert.assertEquals(predicate.getValues(), ["abc", "def"]) - } - - @Test - public void testSingleFieldWithInfixOperator() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.field1][infix]", "abc,def") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - def predicate = predicates.get("type1").iterator().next() - Assert.assertEquals(predicate.getField(), "field1") - Assert.assertEquals(predicate.getOperator(), Operator.INFIX) - Assert.assertEquals(predicate.getValues(), ["abc", "def"]) - } - - @Test(expectedExceptions = [InvalidPredicateException]) - public void testMissingType() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[field1]", "abc,def") - - def predicates = parse(queryParams) - } - - @Test - public void testIntegerFieldType() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.intField]", "1") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - def predicate = predicates.get("type1").iterator().next() - Assert.assertEquals(predicate.getField(), "intField") - Assert.assertEquals(predicate.getOperator(), Operator.IN) - Assert.assertEquals(predicate.getValues(), [1]) - } - - @Test - public void testMultipleIntegerFieldType() { - def queryParams = new MultivaluedHashMap<>() - queryParams.add("filter[type1.intField]", "1,2,3") - - def predicates = parse(queryParams) - Assert.assertTrue(predicates.containsKey("type1")) - - def predicate = predicates.get("type1").iterator().next() - Assert.assertEquals(predicate.getField(), "intField") - Assert.assertEquals(predicate.getOperator(), Operator.IN) - Assert.assertEquals(predicate.getValues(), [1, 2, 3]) - } -} diff --git a/elide-core/src/test/java/com/yahoo/elide/ElideSettingsBuilderTest.java b/elide-core/src/test/java/com/yahoo/elide/ElideSettingsBuilderTest.java new file mode 100644 index 0000000000..3d20b7667d --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/ElideSettingsBuilderTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.utils.coerce.converters.InstantSerde; +import com.yahoo.elide.core.utils.coerce.converters.OffsetDateTimeSerde; +import com.yahoo.elide.core.utils.coerce.converters.Serde; +import com.yahoo.elide.core.utils.coerce.converters.TimeZoneSerde; +import com.yahoo.elide.core.utils.coerce.converters.URLSerde; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.net.URL; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.TimeZone; + + +class ElideSettingsBuilderTest { + + private ElideSettingsBuilder testInstance; + + @Mock + private DataStore dataStore; + + @BeforeEach + public void setUp() { + testInstance = new ElideSettingsBuilder(dataStore).withEntityDictionary(EntityDictionary.builder().build()); + } + + @Test + @SuppressWarnings("rawtypes") + public void shouldBuildSettingsWithDefaultSerdes() { + Map serdes = testInstance.build().getSerdes(); + + assertEquals(InstantSerde.class, serdes.get(Instant.class).getClass()); + assertEquals(OffsetDateTimeSerde.class, serdes.get(OffsetDateTime.class).getClass()); + assertEquals(TimeZoneSerde.class, serdes.get(TimeZone.class).getClass()); + assertEquals(URLSerde.class, serdes.get(URL.class).getClass()); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/audit/LogMessageTest.java b/elide-core/src/test/java/com/yahoo/elide/audit/LogMessageTest.java deleted file mode 100644 index 07f58b6c0c..0000000000 --- a/elide-core/src/test/java/com/yahoo/elide/audit/LogMessageTest.java +++ /dev/null @@ -1,114 +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.audit; - -import com.yahoo.elide.core.EntityDictionary; -import com.yahoo.elide.core.PersistentResource; -import com.yahoo.elide.core.RequestScope; - -import com.google.common.collect.Sets; -import example.Child; -import example.Parent; -import org.testng.Assert; -import org.testng.annotations.BeforeTest; -import org.testng.annotations.Test; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Optional; -import java.util.Random; - -public class LogMessageTest { - private transient PersistentResource childRecord; - private transient PersistentResource friendRecord; - - @BeforeTest - public void setup() { - final EntityDictionary dictionary = new EntityDictionary(new HashMap<>()); - dictionary.bindEntity(Child.class); - dictionary.bindEntity(Parent.class); - - final Child child = new Child(); - child.setId(5); - - final Parent parent = new Parent(); - parent.setId(7); - - final Child friend = new Child(); - friend.setId(9); - child.setFriends(Sets.newHashSet(friend)); - - final RequestScope requestScope = new RequestScope( - null, null, null, null, dictionary, null, new TestAuditLogger()); - - final PersistentResource parentRecord = new PersistentResource<>(parent, requestScope); - childRecord = new PersistentResource<>(parentRecord, child, requestScope); - friendRecord = new PersistentResource<>(childRecord, friend, requestScope); - } - - @Test - public void verifyObjectExpressions() { - final String[] expressions = { "${child.id}", "${parent.getId()}" }; - final LogMessage message = new LogMessage("{0} {1}", childRecord, expressions, 1, Optional.empty()); - Assert.assertEquals("5 7", message.getMessage(), "JEXL substitution evaluates correctly."); - Assert.assertEquals(message.getChangeSpec(), Optional.empty()); - } - - @Test - 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()); - Assert.assertEquals(message.getMessage(), "5 9 7", "JEXL substitution evaluates correctly."); - Assert.assertEquals(defaultMessage.getMessage(), "9", "JEXL substitution evaluates correctly."); - Assert.assertEquals(message.getChangeSpec(), Optional.empty()); - } - - - @Test(expectedExceptions = InvalidSyntaxException.class) - public void invalidExpression() { - final String[] expressions = { "${child.id}, ${%%%}" }; - new LogMessage("{0} {1}", childRecord, expressions, 1, Optional.empty()).getMessage(); - } - - @Test(expectedExceptions = InvalidSyntaxException.class) - public void invalidTemplate() { - final String[] expressions = { "${child.id}" }; - new LogMessage("{}", childRecord, expressions, 1, Optional.empty()).getMessage(); - } - - public static class TestLoggerException extends RuntimeException { - } - - private AuditLogger testAuditLogger = new Slf4jLogger(); - - @Test(threadPoolSize = 10, invocationCount = 10) - public void threadSafeLogger() throws IOException, InterruptedException { - TestLoggerException testException = new TestLoggerException(); - LogMessage failMessage = new LogMessage("test", 0) { - @Override - public String getMessage() { - throw testException; - } - }; - try { - testAuditLogger.log(failMessage); - Thread.sleep(Math.floorMod(new Random().nextInt(), 100)); - testAuditLogger.commit(); - Assert.fail("Exception expected"); - } catch (TestLoggerException e) { - Assert.assertSame(e, testException); - } - - // should not cause another exception - try { - testAuditLogger.commit(); - } catch (TestLoggerException e) { - Assert.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 deleted file mode 100644 index 4859ac54e7..0000000000 --- a/elide-core/src/test/java/com/yahoo/elide/audit/TestAuditLogger.java +++ /dev/null @@ -1,20 +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.audit; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -public class TestAuditLogger extends AuditLogger { - @Override - public void commit() throws IOException { - } - - public List getMessages() { - return new ArrayList<>(this.messages.get()); - } -} 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 deleted file mode 100644 index bbe171d269..0000000000 --- a/elide-core/src/test/java/com/yahoo/elide/core/EntityDictionaryTest.java +++ /dev/null @@ -1,212 +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; - -import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.security.checks.prefab.Role; -import example.Child; -import example.FieldAnnotations; -import example.FunWithPermissions; -import example.Left; -import example.Parent; -import example.Right; -import example.StringId; -import example.User; -import org.testng.Assert; -import org.testng.annotations.BeforeTest; -import org.testng.annotations.Test; - -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; -import java.lang.annotation.Annotation; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -public class EntityDictionaryTest extends EntityDictionary { - - //Test class to validate inheritance logic - private class Friend extends Child { } - - public EntityDictionaryTest() { - super(Collections.EMPTY_MAP); - } - - @BeforeTest - public void init() { - this.bindEntity(FunWithPermissions.class); - this.bindEntity(Parent.class); - this.bindEntity(Child.class); - this.bindEntity(User.class); - this.bindEntity(Left.class); - this.bindEntity(Right.class); - this.bindEntity(StringId.class); - this.bindEntity(Friend.class); - this.bindEntity(FieldAnnotations.class); - - checkNames.forcePut("user has all access", Role.ALL.class); - } - - @Test - public void testFindCheckByExpression() { - Assert.assertEquals(getCheckIdentifier(Role.ALL.class), "user has all access"); - Assert.assertEquals(getCheckIdentifier(Role.NONE.class), "Prefab.Role.None"); - } - - @Test - public void testGetAttributeOrRelationAnnotation() { - String[] fields = {"field1", "field2", "field3", "relation1", "relation2"}; - Annotation annotation; - for (String field : fields) { - annotation = this.getAttributeOrRelationAnnotation(FunWithPermissions.class, ReadPermission.class, field); - Assert.assertTrue(annotation instanceof ReadPermission, "Every field should return a ReadPermission annotation"); - } - } - - @Test - public void testGetParameterizedType() { - Class type; - - FunWithPermissions fun = new FunWithPermissions(); - - type = getParameterizedType(fun, "relation2"); - Assert.assertEquals(type, Child.class, "A set of Child objects should return Child.class"); - - type = getParameterizedType(fun, "relation3"); - Assert.assertEquals(type, Child.class, "A Child object should return Child.class"); - } - - @Test - public void testGetInverseRelationshipOwningSide() { - Assert.assertEquals(getRelationInverse(Parent.class, "children"), "parents", - "The inverse relationship of children should be parents"); - } - - @Test - public void testGetInverseRelationshipOwnedSide() { - Assert.assertEquals(getRelationInverse(Child.class, "parents"), "children", - "The inverse relationship of children should be parents"); - } - - @Test - public void testComputedAttributeIsExposed() { - List attributes = getAttributes(User.class); - Assert.assertTrue(attributes.contains("password")); - } - - @Test - public void testExcludedAttributeIsNotExposed() { - List attributes = getAttributes(User.class); - Assert.assertFalse(attributes.contains("reversedPassword")); - } - - - @Test - public void testGetIdAnnotations() throws Exception { - - Collection expectedAnnotationClasses = Arrays.asList(new Class[]{Id.class, GeneratedValue.class}); - Collection actualAnnotationsClasses = getIdAnnotations(new Parent()).stream() - .map(Annotation::annotationType) - .collect(Collectors.toList()); - - Assert.assertEquals(actualAnnotationsClasses, expectedAnnotationClasses, - "getIdAnnotations returns annotations on the ID field of the given class"); - } - - @Test - public void testGetIdAnnotationsNoId() throws Exception { - - Collection expectedAnnotation = Collections.emptyList(); - Collection actualAnnotations = getIdAnnotations(new NoId()); - - Assert.assertEquals(actualAnnotations, expectedAnnotation, - "getIdAnnotations returns an empty collection if there is no ID field for given class"); - } - - @Entity - class NoId { - - } - - @Test - public void testGetIdAnnotationsSubClass() throws Exception { - - Collection expectedAnnotationClasses = Arrays.asList(new Class[]{Id.class, GeneratedValue.class}); - Collection actualAnnotationsClasses = getIdAnnotations(new Friend()).stream() - .map(Annotation::annotationType) - .collect(Collectors.toList()); - - Assert.assertEquals(actualAnnotationsClasses, expectedAnnotationClasses, - "getIdAnnotations returns annotations on the ID field when defined in a super class"); - } - - @Test - public void testIsSharableTrue() throws Exception { - Assert.assertTrue(isShareable(Right.class)); - } - - @Test - public void testIsSharableFalse() throws Exception { - Assert.assertFalse(isShareable(Left.class)); - } - - @Test - public void testGetIdType() throws Exception { - - Assert.assertEquals(getIdType(Parent.class), long.class, - "getIdType returns the type of the ID field of the given class"); - - Assert.assertEquals(getIdType(StringId.class), String.class, - "getIdType returns the type of the ID field of the given class"); - - Assert.assertEquals(getIdType(NoId.class), null, - "getIdType returns null if ID field is missing"); - - Assert.assertEquals(getIdType(Friend.class), long.class, - "getIdType returns the type of the ID field when defined in a super class"); - } - - @Test - public void testGetType() throws Exception { - - Assert.assertEquals(getType(FieldAnnotations.class, "id"), Long.class, - "getType returns the type of the ID field of the given class"); - - Assert.assertEquals(getType(FieldAnnotations.class, "publicField"), long.class, - "getType returns the type of attribute when Column annotation is on a field"); - - Assert.assertEquals(getType(FieldAnnotations.class, "privateField"), Boolean.class, - "getType returns the type of attribute when Column annotation is on a getter"); - - Assert.assertEquals(getType(FieldAnnotations.class, "missingField"), null, - "getId returns null if attribute is missing"); - - Assert.assertEquals(getType(Parent.class, "children"), Set.class, - "getType returns the type of relationship fields"); - - Assert.assertEquals(getType(Friend.class, "name"), String.class, - "getType returns the type of attribute when defined in a super class"); - } - - @Test(expectedExceptions = IllegalArgumentException.class) - public void testGetTypUnknownEntityException() { - getType(Object.class, "id"); - } - - @Test - public void testNoExcludedFieldsReturned() { - List attrs = getAttributes(Child.class); - List rels = getRelationships(Child.class); - Assert.assertTrue(!attrs.contains("excludedEntity") && !attrs.contains("excludedRelationship") - && !attrs.contains("excludedEntityList")); - Assert.assertTrue(!rels.contains("excludedEntity") && !rels.contains("excludedRelationship") - && !rels.contains("excludedEntityList")); - } -} 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 deleted file mode 100644 index 43e853398b..0000000000 --- a/elide-core/src/test/java/com/yahoo/elide/core/LifeCycleTest.java +++ /dev/null @@ -1,252 +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; - -import com.yahoo.elide.Elide; -import com.yahoo.elide.audit.AuditLogger; -import com.yahoo.elide.security.User; -import com.yahoo.elide.security.checks.Check; -import example.Author; -import example.Book; -import org.testng.Assert; -import org.testng.annotations.Test; - -import javax.ws.rs.core.MultivaluedHashMap; -import javax.ws.rs.core.MultivaluedMap; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -import static org.mockito.Matchers.anyObject; -import static org.mockito.Matchers.eq; -import static org.mockito.Matchers.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; - -/** - * Tests the invocation & sequencing of DataStoreTransaction method invocations and life cycle events. - */ -public class LifeCycleTest { - public class TestEntityDictionary extends EntityDictionary { - public TestEntityDictionary(Map> checks) { - super(checks); - } - - @Override - public Class lookupEntityClass(Class objClass) { - // Special handling for mocked Book class which has Entity annotation - if (objClass.getName().contains("$MockitoMock$")) { - objClass = objClass.getSuperclass(); - } - return super.lookupEntityClass(objClass); - } - } - - private static final AuditLogger MOCK_AUDIT_LOGGER = mock(AuditLogger.class); - private EntityDictionary dictionary; - - LifeCycleTest() { - dictionary = new TestEntityDictionary(new HashMap<>()); - dictionary.bindEntity(Book.class); - dictionary.bindEntity(Author.class); - } - - @Test - public void testElideCreate() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); - - Elide.Builder builder = new Elide.Builder(store); - builder.withAuditLogger(MOCK_AUDIT_LOGGER); - builder.withEntityDictionary(dictionary); - Elide elide = builder.build(); - - String bookBody = "{\"data\": {\"type\":\"book\",\"attributes\": {\"title\":\"Grapes of Wrath\"}}}"; - - when(store.beginTransaction()).thenReturn(tx); - when(tx.createObject(Book.class)).thenReturn(book); - - elide.post("/book", bookBody, null); - verify(tx).accessUser(null); - verify(tx).preCommit(); - - verify(tx, times(1)).save(book); - verify(tx).flush(); - verify(tx).commit(); - verify(tx).close(); - } - - @Test - public void testElideGet() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); - - Elide.Builder builder = new Elide.Builder(store); - builder.withAuditLogger(MOCK_AUDIT_LOGGER); - builder.withEntityDictionary(dictionary); - Elide elide = builder.build(); - - when(store.beginReadTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), eq(1L), anyObject())).thenReturn(book); - - MultivaluedMap headers = new MultivaluedHashMap<>(); - elide.get("/book/1", headers, null); - verify(tx).accessUser(null); - verify(tx).preCommit(); - verify(tx).flush(); - verify(tx).commit(); - 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.Builder builder = new Elide.Builder(store); - builder.withAuditLogger(MOCK_AUDIT_LOGGER); - builder.withEntityDictionary(dictionary); - Elide elide = builder.build(); - - when(book.getId()).thenReturn(new Long(1)); - when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), eq(1L), anyObject())).thenReturn(book); - - String bookBody = "{\"data\":{\"type\":\"book\",\"id\":1,\"attributes\": {\"title\":\"Grapes of Wrath\"}}}"; - - String contentType = "application/vnd.api+json"; - elide.patch(contentType, contentType, "/book/1", bookBody, null); - verify(tx).accessUser(null); - verify(tx).preCommit(); - - verify(tx).save(book); - verify(tx).flush(); - verify(tx).commit(); - verify(tx).close(); - } - - @Test - public void testElideDelete() throws Exception { - DataStore store = mock(DataStore.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - Book book = mock(Book.class); - - Elide.Builder builder = new Elide.Builder(store); - builder.withAuditLogger(MOCK_AUDIT_LOGGER); - builder.withEntityDictionary(dictionary); - Elide elide = builder.build(); - - when(book.getId()).thenReturn(new Long(1)); - when(store.beginTransaction()).thenReturn(tx); - when(tx.loadObject(eq(Book.class), eq(1L), anyObject())).thenReturn(book); - - elide.delete("/book/1", "", null); - verify(tx).accessUser(null); - verify(tx).preCommit(); - - verify(tx).delete(book); - verify(tx).flush(); - verify(tx).commit(); - verify(tx).close(); - } - - @Test - public void testOnCreate() { - Book book = mock(Book.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.createObject(Book.class)).thenReturn(book); - RequestScope scope = new RequestScope(null, null, tx, new User(1), dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource resource = PersistentResource.createObject(Book.class, scope, "uuid"); - Assert.assertNotNull(resource); - verify(book, times(1)).onCreateBook(); - verify(book, times(0)).onDeleteBook(); - verify(book, times(0)).onCommitBook(); - verify(book, times(0)).onUpdateTitle(); - verify(book, times(0)).onCommitTitle(); - } - - @Test - public void createObjectOnCommit() { - Book book = mock(Book.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.createObject(Book.class)).thenReturn(book); - RequestScope scope = new RequestScope(null, null, tx, new User(1), dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource resource = PersistentResource.createObject(Book.class, scope, "uuid"); - scope.runCommitTriggers(); - Assert.assertNotNull(resource); - verify(book, times(1)).onCreateBook(); - verify(book, times(0)).onDeleteBook(); - verify(book, times(1)).onCommitBook(); - verify(book, times(0)).onUpdateTitle(); - verify(book, times(0)).onCommitTitle(); - } - - @Test - public void loadRecordOnCommit() { - Book book = mock(Book.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.loadObject(eq(Book.class), eq(1L), anyObject())).thenReturn(book); - RequestScope scope = new RequestScope(null, null, tx, new User(1), dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource resource = PersistentResource.loadRecord(Book.class, "1", scope); - scope.runCommitTriggers(); - Assert.assertNotNull(resource); - verify(book, times(0)).onCreateBook(); - verify(book, times(0)).onDeleteBook(); - verify(book, times(1)).onCommitBook(); - verify(book, times(0)).onUpdateTitle(); - verify(book, times(0)).onCommitTitle(); - } - - @Test - public void loadRecordsOnCommit() { - Book book = mock(Book.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.loadObjects(eq(Book.class), isA(FilterScope.class))).thenReturn(Arrays.asList(book)); - RequestScope scope = new RequestScope(null, null, tx, new User(1), dictionary, null, MOCK_AUDIT_LOGGER); - Set> resources = PersistentResource.loadRecords(Book.class, scope); - scope.runCommitTriggers(); - Assert.assertEquals(resources.size(), 1); - verify(book, times(0)).onCreateBook(); - verify(book, times(0)).onDeleteBook(); - verify(book, times(1)).onCommitBook(); - verify(book, times(0)).onUpdateTitle(); - verify(book, times(0)).onCommitTitle(); - } - - @Test - public void testOnUpdate() { - Book book = mock(Book.class); - RequestScope scope = new RequestScope(null, null, null, new User(1), dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource resource = new PersistentResource(book, scope); - resource.setValue("title", "new title"); - scope.runCommitTriggers(); - verify(book, times(0)).onCreateBook(); - verify(book, times(0)).onDeleteBook(); - verify(book, times(0)).onCommitBook(); - verify(book, times(1)).onUpdateTitle(); - verify(book, times(1)).onCommitTitle(); - } - - @Test - public void testOnDelete() { - Book book = mock(Book.class); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - RequestScope scope = new RequestScope(null, null, tx, new User(1), dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource resource = new PersistentResource(book, scope); - resource.deleteResource(); - verify(book, times(0)).onCreateBook(); - verify(book, times(1)).onDeleteBook(); - verify(book, times(0)).onCommitBook(); - verify(book, times(0)).onCommitTitle(); - verify(book, times(0)).onUpdateTitle(); - } -} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PaginationLogicTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PaginationLogicTest.java deleted file mode 100644 index 50815787fb..0000000000 --- a/elide-core/src/test/java/com/yahoo/elide/core/PaginationLogicTest.java +++ /dev/null @@ -1,133 +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; - -import com.yahoo.elide.core.exceptions.InvalidValueException; -import com.yahoo.elide.core.pagination.Pagination; -import org.glassfish.jersey.internal.util.collection.MultivaluedStringMap; -import org.testng.Assert; -import org.testng.annotations.Test; - -import javax.ws.rs.core.MultivaluedMap; - -/** - * Tests parsing the page params for json-api pagination. - */ -public class PaginationLogicTest { - - @Test - public void shouldParseQueryParamsForCurrentPageAndPageSize() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[size]", "10"); - queryParams.add("page[number]", "2"); - - Pagination pageData = Pagination.parseQueryParams(queryParams); - // page based strategy uses human readable paging - non-zero index - // page 2 becomes (1)*10 so 10 since we shift to zero based index - Assert.assertEquals(pageData.getOffset(), 10); - Assert.assertEquals(pageData.getLimit(), 10); - } - - @Test(expectedExceptions = InvalidValueException.class) - public void shouldThrowExceptionForNegativePageNumber() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[size]", "10"); - queryParams.add("page[number]", "-2"); - - Pagination pageData = Pagination.parseQueryParams(queryParams); - } - - @Test(expectedExceptions = InvalidValueException.class) - public void shouldThrowExceptionForNegativePageSize() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[size]", "-10"); - queryParams.add("page[number]", "2"); - Pagination.parseQueryParams(queryParams); - } - - @Test - public void shouldParseQueryParamsForOffsetAndLimit() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[limit]", "10"); - queryParams.add("page[offset]", "2"); - - Pagination pageData = Pagination.parseQueryParams(queryParams); - // offset is direct correlation to start field in query - Assert.assertEquals(pageData.getOffset(), 2); - Assert.assertEquals(pageData.getLimit(), 10); - } - - @Test - public void shouldUseDefaultsWhenMissingCurrentPageAndPageSize() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - Pagination pageData = Pagination.parseQueryParams(queryParams); - Assert.assertEquals(pageData.getOffset(), Pagination.DEFAULT_OFFSET); - Assert.assertEquals(pageData.getLimit(), Pagination.DEFAULT_PAGE_LIMIT); - } - - @Test(expectedExceptions = InvalidValueException.class) - public void neverExceedMaxPageSize() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[size]", "25000"); - Pagination.parseQueryParams(queryParams); - } - - @Test(expectedExceptions = InvalidValueException.class) - public void invalidUsageOfPaginationParameters() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[size]", "10"); - queryParams.add("page[offset]", "100"); - Pagination.parseQueryParams(queryParams); - } - - @Test - public void pageBasedPaginationWithDefaultSize() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[number]", "2"); - Pagination pageData = Pagination.parseQueryParams(queryParams); - Assert.assertEquals(pageData.getLimit(), Pagination.DEFAULT_PAGE_LIMIT); - Assert.assertEquals(pageData.getOffset(), Pagination.DEFAULT_PAGE_LIMIT); - } - - @Test (expectedExceptions = InvalidValueException.class) - public void shouldThrowExceptionForNonIntPageParamValues() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[size]", "2.5"); - Pagination.parseQueryParams(queryParams); - } - - @Test (expectedExceptions = InvalidValueException.class) - public void shouldThrowExceptionForInvalidPageParams() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[random]", "1"); - Pagination.parseQueryParams(queryParams); - } - - @Test - public void shouldSetGenerateTotals() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - queryParams.add("page[totals]", null); - Pagination pageData = Pagination.parseQueryParams(queryParams); - Assert.assertTrue(pageData.isGenerateTotals()); - } - - @Test - public void shouldNotSetGenerateTotals() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - Pagination pageData = Pagination.parseQueryParams(queryParams); - Assert.assertFalse(pageData.isGenerateTotals()); - } - - - @Test - public void shouldUseDefaultsWhenNoParams() { - MultivaluedMap queryParams = new MultivaluedStringMap(); - - Pagination pageData = Pagination.parseQueryParams(queryParams); - Assert.assertEquals(pageData.getOffset(), 0); - Assert.assertEquals(pageData.getLimit(), Pagination.DEFAULT_PAGE_LIMIT); - } -} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PathTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PathTest.java new file mode 100644 index 0000000000..a85252ac35 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/PathTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021, 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 org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import example.Book; +import example.Editor; +import org.junit.jupiter.api.Test; + +import java.util.List; + +public class PathTest { + + @Test + public void testIsComputed() { + + EntityDictionary dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); + dictionary.bindEntity(Editor.class); + + Path computedRelationshipPath = new Path(List.of( + new Path.PathElement(Book.class, Editor.class, "editor"), + new Path.PathElement(Editor.class, String.class, "firstName") + )); + + Path computedAttributePath = new Path(List.of( + new Path.PathElement(Editor.class, String.class, "fullName") + )); + + Path attributePath = new Path(List.of( + new Path.PathElement(Book.class, String.class, "title") + )); + + assertTrue(computedRelationshipPath.isComputed(dictionary)); + assertTrue(computedAttributePath.isComputed(dictionary)); + assertFalse(attributePath.isComputed(dictionary)); + } +} 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 deleted file mode 100644 index fbdf35b3d9..0000000000 --- a/elide-core/src/test/java/com/yahoo/elide/core/PermissionAnnotationTest.java +++ /dev/null @@ -1,130 +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; - -import com.yahoo.elide.annotation.CreatePermission; -import com.yahoo.elide.annotation.DeletePermission; -import com.yahoo.elide.annotation.ReadPermission; -import com.yahoo.elide.annotation.UpdatePermission; -import com.yahoo.elide.audit.AuditLogger; -import com.yahoo.elide.audit.TestAuditLogger; -import com.yahoo.elide.core.exceptions.ForbiddenAccessException; -import com.yahoo.elide.security.PermissionExecutor; -import com.yahoo.elide.security.User; - -import com.yahoo.elide.security.executors.ActivePermissionExecutor; -import example.FunWithPermissions; -import org.testng.annotations.BeforeTest; -import org.testng.annotations.Test; - -import java.util.HashMap; - -/** - * Tests audit functions inside RecordDao. - */ -public class PermissionAnnotationTest { - private PersistentResource funRecord; - private final User goodUser; - private PersistentResource badRecord; - private final User badUser; - private final EntityDictionary dictionary; - - public PermissionAnnotationTest() { - goodUser = new User(3); - badUser = new User(-1); - dictionary = new EntityDictionary(new HashMap<>()); - } - - @BeforeTest - public void setup() { - dictionary.bindEntity(FunWithPermissions.class); - - FunWithPermissions fun = new FunWithPermissions(); - fun.setId(1); - - AuditLogger testLogger = new TestAuditLogger(); - funRecord = new PersistentResource<>(fun, - new RequestScope(null, null, null, goodUser, dictionary, null, testLogger)); - badRecord = new PersistentResource<>(fun, - new RequestScope(null, null, null, badUser, dictionary, null, testLogger)); - } - - @Test - public void testClassAnyOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(funRecord.getRequestScope()); - permissionExecutor.checkPermission(ReadPermission.class, funRecord); - permissionExecutor.checkPermission(UpdatePermission.class, funRecord); - permissionExecutor.checkPermission(CreatePermission.class, funRecord); - } - - @Test(expectedExceptions = ForbiddenAccessException.class) - public void testClassAllNotOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(badRecord.getRequestScope()); - permissionExecutor.checkPermission(DeletePermission.class, funRecord); - } - - @Test - public void testFieldPermissionOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(funRecord.getRequestScope()); - permissionExecutor.checkSpecificFieldPermissions(funRecord, null, ReadPermission.class, "field3"); - permissionExecutor.checkSpecificFieldPermissions(funRecord, null, ReadPermission.class, "relation1"); - permissionExecutor.checkSpecificFieldPermissions(funRecord, null, ReadPermission.class, "relation2"); - } - - @Test(expectedExceptions = ForbiddenAccessException.class) - public void testField3PermissionNotOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(badRecord.getRequestScope()); - permissionExecutor.checkSpecificFieldPermissions(badRecord, null, ReadPermission.class, "field3"); - } - - @Test(expectedExceptions = ForbiddenAccessException.class) - public void testRelation1PermissionNotOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(badRecord.getRequestScope()); - permissionExecutor.checkSpecificFieldPermissions(badRecord, null, ReadPermission.class, "relation1"); - } - - @Test(expectedExceptions = ForbiddenAccessException.class) - public void testRelation2PermissionNotOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(badRecord.getRequestScope()); - permissionExecutor.checkSpecificFieldPermissions(badRecord, null, ReadPermission.class, "relation2"); - } - - /** - * Verifies ANY where the first fails but the last succeeds. - */ - @Test() - public void testField5PermissionOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(badRecord.getRequestScope()); - permissionExecutor.checkSpecificFieldPermissions(badRecord, null, ReadPermission.class, "field5"); - } - - /** - * Verifies ALL where the first fails. - */ - @Test(expectedExceptions = ForbiddenAccessException.class) - public void testField6PermissionNotOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(badRecord.getRequestScope()); - permissionExecutor.checkSpecificFieldPermissions(badRecord, null, ReadPermission.class, "field6"); - } - - /** - * Verifies ALL where the last fails. - */ - @Test(expectedExceptions = ForbiddenAccessException.class) - public void testField7PermissionNotOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(badRecord.getRequestScope()); - permissionExecutor.checkSpecificFieldPermissions(badRecord, null, ReadPermission.class, "field7"); - } - - /** - * Verifies ANY where all fail. - */ - @Test(expectedExceptions = ForbiddenAccessException.class) - public void testField8PermissionNotOk() { - final PermissionExecutor permissionExecutor = new ActivePermissionExecutor(badRecord.getRequestScope()); - permissionExecutor.checkSpecificFieldPermissions(badRecord, null, ReadPermission.class, "field8"); - } -} 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 new file mode 100644 index 0000000000..9c08851537 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/PersistenceResourceTestSetup.java @@ -0,0 +1,295 @@ +/* + * 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; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static org.mockito.Mockito.mock; +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; +import com.yahoo.elide.core.audit.AuditLogger; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.TestDictionary; +import com.yahoo.elide.core.lifecycle.LifeCycleHook; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.TestUser; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.security.checks.OperationCheck; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.google.common.collect.Sets; +import example.Author; +import example.Book; +import example.Child; +import example.Company; +import example.ComputedBean; +import example.FirstClassFields; +import example.FunWithPermissions; +import example.Invoice; +import example.Job; +import example.Left; +import example.LineItem; +import example.MapColorShape; +import example.NoDeleteEntity; +import example.NoReadEntity; +import example.NoShareEntity; +import example.NoUpdateEntity; +import example.Parent; +import example.Publisher; +import example.Right; +import example.UpdateAndCreate; +import example.nontransferable.ContainerWithPackageShare; +import example.nontransferable.NoTransferBiDirectional; +import example.nontransferable.ShareableWithPackageShare; +import example.nontransferable.StrictNoTransfer; +import example.nontransferable.Untransferable; +import io.reactivex.Observable; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import nocreate.NoCreateEntity; + +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.ManyToMany; +import javax.persistence.OneToOne; +import javax.ws.rs.core.MultivaluedMap; + +public class PersistenceResourceTestSetup extends PersistentResource { + private static final AuditLogger MOCK_AUDIT_LOGGER = mock(AuditLogger.class); + + protected final ElideSettings elideSettings; + + protected static LifeCycleHook bookUpdatePrice = mock(LifeCycleHook.class); + + protected static EntityDictionary initDictionary() { + EntityDictionary dictionary = TestDictionary.getTestDictionary(); + + dictionary.bindEntity(UpdateAndCreate.class); + dictionary.bindEntity(Author.class); + dictionary.bindEntity(Book.class); + dictionary.bindEntity(Publisher.class); + dictionary.bindEntity(Child.class); + dictionary.bindEntity(Parent.class); + dictionary.bindEntity(FunWithPermissions.class); + dictionary.bindEntity(Job.class); + dictionary.bindEntity(Left.class); + dictionary.bindEntity(Right.class); + dictionary.bindEntity(NoReadEntity.class); + dictionary.bindEntity(NoDeleteEntity.class); + dictionary.bindEntity(NoUpdateEntity.class); + dictionary.bindEntity(NoCreateEntity.class); + dictionary.bindEntity(NoShareEntity.class); + dictionary.bindEntity(example.User.class); + dictionary.bindEntity(FirstClassFields.class); + dictionary.bindEntity(MapColorShape.class); + dictionary.bindEntity(PersistentResourceTest.ChangeSpecModel.class); + dictionary.bindEntity(PersistentResourceTest.ChangeSpecChild.class); + dictionary.bindEntity(Invoice.class); + dictionary.bindEntity(LineItem.class); + dictionary.bindEntity(ComputedBean.class); + dictionary.bindEntity(ContainerWithPackageShare.class); + dictionary.bindEntity(ShareableWithPackageShare.class); + dictionary.bindEntity(NoTransferBiDirectional.class); + dictionary.bindEntity(StrictNoTransfer.class); + dictionary.bindEntity(Untransferable.class); + dictionary.bindEntity(Company.class); + + dictionary.bindTrigger(Book.class, "price", + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.PRESECURITY, bookUpdatePrice); + + return dictionary; + } + + protected static ElideSettings initSettings() { + return new ElideSettingsBuilder(null) + .withEntityDictionary(initDictionary()) + .withAuditLogger(MOCK_AUDIT_LOGGER) + .withDefaultMaxPageSize(10) + .withDefaultPageSize(10) + .build(); + } + + public PersistenceResourceTestSetup() { + super( + new Child(), + null, // new request scope + new Child == cannot possibly be a UUID for this object + new RequestScope(null, null, NO_VERSION, null, null, null, null, null, UUID.randomUUID(), + initSettings() + ) + ); + + elideSettings = initSettings(); + } + + protected static Child newChild(int id) { + Child child = new Child(); + child.setId(id); + child.setParents(new HashSet<>()); + child.setFriends(new HashSet<>()); + return child; + } + + protected static Child newChild(int id, String name) { + Child child = newChild(id); + child.setName(name); + return child; + } + + protected RequestScope buildRequestScope(DataStoreTransaction tx, User user) { + return buildRequestScope(null, tx, user, null); + } + + protected RequestScope buildRequestScope(String path, DataStoreTransaction tx, User user, MultivaluedMap queryParams) { + return new RequestScope(null, path, NO_VERSION, null, tx, user, queryParams, null, UUID.randomUUID(), elideSettings); + } + + protected PersistentResource bootstrapPersistentResource(T obj) { + return bootstrapPersistentResource(obj, mock(DataStoreTransaction.class)); + } + + protected PersistentResource bootstrapPersistentResource(T obj, DataStoreTransaction tx) { + User goodUser = new TestUser("1"); + RequestScope requestScope = new RequestScope(null, null, NO_VERSION, null, tx, goodUser, null, null, UUID.randomUUID(), elideSettings); + return new PersistentResource<>(obj, requestScope.getUUIDFor(obj), requestScope); + } + + protected RequestScope getUserScope(User user, AuditLogger auditLogger) { + return new RequestScope(null, null, NO_VERSION, new JsonApiDocument(), null, user, null, null, UUID.randomUUID(), + new ElideSettingsBuilder(null) + .withEntityDictionary(dictionary) + .withAuditLogger(auditLogger) + .build()); + } + + // Testing constructor, setId and non-null empty sets + protected static Parent newParent(int id) { + Parent parent = new Parent(); + parent.setId(id); + parent.setChildren(new HashSet<>()); + parent.setSpouses(new HashSet<>()); + return parent; + } + + protected Parent newParent(int id, Child child) { + Parent parent = new Parent(); + parent.setId(id); + parent.setChildren(Sets.newHashSet(child)); + parent.setSpouses(new HashSet<>()); + return parent; + } + + protected Company newCompany(String id) { + final Company company = new Company(); + company.setId(id); + company.setDescription("company"); + return company; + } + + /* ChangeSpec-specific test elements */ + @Entity + @Include(rootLevel = false) + @CreatePermission(expression = "Prefab.Role.All") + @ReadPermission(expression = "Prefab.Role.All") + @UpdatePermission(expression = "Prefab.Role.None") + @DeletePermission(expression = "Prefab.Role.All") + public static final class ChangeSpecModel { + @Id + public long id; + + @ReadPermission(expression = "Prefab.Role.None") + @UpdatePermission(expression = "Prefab.Role.None") + public Predicate checkFunction; + + @UpdatePermission(expression = "changeSpecNonCollection") + public String testAttr; + + @UpdatePermission(expression = "changeSpecCollection") + public List testColl; + + @OneToOne + @UpdatePermission(expression = "changeSpecNonCollection") + public ChangeSpecChild child; + + @ManyToMany + @UpdatePermission(expression = "changeSpecCollection") + public List otherKids; + + public ChangeSpecModel(final Predicate checkFunction) { + this.checkFunction = checkFunction; + } + } + + @Entity + @Include(rootLevel = false) + @EqualsAndHashCode + @AllArgsConstructor + @CreatePermission(expression = "Prefab.Role.All") + @ReadPermission(expression = "Prefab.Role.All") + @UpdatePermission(expression = "Prefab.Role.All") + @DeletePermission(expression = "Prefab.Role.All") + public static final class ChangeSpecChild { + @Id + public long id; + } + + public static final class ChangeSpecCollection extends OperationCheck { + + @Override + public boolean ok(Object object, com.yahoo.elide.core.security.RequestScope requestScope, Optional changeSpec) { + if (changeSpec.isPresent() && (object instanceof ChangeSpecModel)) { + ChangeSpec spec = changeSpec.get(); + if (!(spec.getModified() instanceof Collection)) { + return false; + } + return ((ChangeSpecModel) object).checkFunction.test(spec); + } + throw new IllegalStateException("Something is terribly wrong :("); + } + } + + public static final class ChangeSpecNonCollection extends OperationCheck { + + @Override + public boolean ok(Object object, com.yahoo.elide.core.security.RequestScope requestScope, Optional changeSpec) { + return changeSpec.filter(c -> object instanceof ChangeSpecModel) + .map(c -> ((ChangeSpecModel) object).checkFunction.test(c)) + .orElseThrow(() -> new IllegalStateException("Something is terribly wrong :(")); + } + } + + public Set getRelation(PersistentResource resource, String relation) { + Observable resources = + resource.getRelationCheckedFiltered(getRelationship(resource.getResourceType(), relation)); + + return resources.toList(LinkedHashSet::new).blockingGet(); + } + + public com.yahoo.elide.core.request.Relationship getRelationship(Type type, String name) { + return com.yahoo.elide.core.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 new file mode 100644 index 0000000000..c436b8ff08 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceNoopUpdateTest.java @@ -0,0 +1,105 @@ +/* + * 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; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +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.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.security.TestUser; +import com.yahoo.elide.core.security.User; +import example.Child; +import example.FunWithPermissions; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public class PersistentResourceNoopUpdateTest extends PersistenceResourceTestSetup { + private final RequestScope goodUserScope; + private final User goodUser; + PersistentResourceNoopUpdateTest() { + goodUser = new TestUser("1"); + goodUserScope = new RequestScope(null, null, NO_VERSION, null, + mock(DataStoreTransaction.class), goodUser, null, null, UUID.randomUUID(), elideSettings); + initDictionary(); + reset(goodUserScope.getTransaction()); + } + + @Test + public void testNOOPToOneAddRelation() { + FunWithPermissions fun = new FunWithPermissions(); + Child child = newChild(1); + fun.setRelation3(child); + + DataStoreTransaction tx = mock(DataStoreTransaction.class); + + RequestScope goodScope = new RequestScope(null, null, NO_VERSION, null, tx, goodUser, null, null, UUID.randomUUID(), elideSettings); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + + when(tx.getToOneRelation(eq(tx), eq(fun), any(), any())).thenReturn(child); + + //We do not want the update to one method to be called when we add the existing entity to the relation + funResource.addRelation("relation3", childResource); + + verify(tx, never()).updateToOneRelation(eq(tx), eq(fun), any(), any(), eq(goodScope)); + } + + @Test + public void testToOneAddRelation() { + FunWithPermissions fun = new FunWithPermissions(); + Child child = newChild(1); + + DataStoreTransaction tx = mock(DataStoreTransaction.class); + + RequestScope goodScope = new RequestScope(null, null, NO_VERSION, null, tx, goodUser, null, null, UUID.randomUUID(), elideSettings); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + funResource.addRelation("relation3", childResource); + + verify(tx, times(1)).updateToOneRelation(eq(tx), eq(fun), any(), any(), eq(goodScope)); + } + + @Test + public void testNOOPToManyAddRelation() { + FunWithPermissions fun = new FunWithPermissions(); + Child child = newChild(1); + Set children = new HashSet<>(); + children.add(child); + fun.setRelation1(children); + + DataStoreTransaction tx = mock(DataStoreTransaction.class); + + RequestScope goodScope = new RequestScope(null, null, NO_VERSION, null, tx, goodUser, null, null, UUID.randomUUID(), elideSettings); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, null, goodScope); + //We do not want the update to one method to be called when we add the existing entity to the relation + funResource.addRelation("relation1", childResource); + verify(tx, never()).updateToManyRelation(eq(tx), eq(child), eq("relation1"), any(), any(), eq(goodScope)); + } + + @Test + public void testToManyAddRelation() { + FunWithPermissions fun = new FunWithPermissions(); + Child child = newChild(1); + + DataStoreTransaction tx = mock(DataStoreTransaction.class); + + RequestScope goodScope = new RequestScope(null, null, NO_VERSION, null, tx, goodUser, null, null, UUID.randomUUID(), elideSettings); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, null, goodScope); + funResource.addRelation("relation1", childResource); + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(fun), eq("relation1"), any(), any(), eq(goodScope)); + } +} 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 4b30bf7dcb..109f9b6e15 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 @@ -1,13 +1,22 @@ /* - * Copyright 2016, Yahoo Inc. + * 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; -import static org.mockito.Matchers.any; -import static org.mockito.Matchers.anySet; -import static org.mockito.Matchers.eq; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRESECURITY; +import static com.yahoo.elide.core.dictionary.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; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; @@ -16,187 +25,359 @@ import static org.mockito.Mockito.when; import com.yahoo.elide.annotation.Audit; -import com.yahoo.elide.annotation.CreatePermission; -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.audit.LogMessage; -import com.yahoo.elide.audit.TestAuditLogger; +import com.yahoo.elide.core.audit.LogMessage; +import com.yahoo.elide.core.audit.TestAuditLogger; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.exceptions.InvalidAttributeException; import com.yahoo.elide.core.exceptions.InvalidObjectIdentifierException; import com.yahoo.elide.core.exceptions.InvalidValueException; +import com.yahoo.elide.core.filter.Operator; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.lifecycle.CRUDEvent; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.TestUser; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.jsonapi.extensions.PatchRequestScope; import com.yahoo.elide.jsonapi.models.Data; -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.jsonapi.models.ResourceIdentifier; -import com.yahoo.elide.security.ChangeSpec; -import com.yahoo.elide.security.User; -import com.yahoo.elide.security.checks.OperationCheck; -import com.yahoo.elide.security.checks.prefab.Role; -import com.google.common.base.Function; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import com.google.common.collect.Sets; -import org.mockito.Answers; -import org.testng.Assert; -import org.testng.annotations.BeforeTest; -import org.testng.annotations.Test; - +import example.Address; +import example.Author; +import example.Book; import example.Child; import example.Color; +import example.Company; +import example.ComputedBean; import example.FirstClassFields; import example.FunWithPermissions; +import example.GeoLocation; +import example.Invoice; +import example.Job; import example.Left; +import example.LineItem; import example.MapColorShape; import example.NoDeleteEntity; import example.NoReadEntity; import example.NoShareEntity; import example.NoUpdateEntity; import example.Parent; +import example.Price; import example.Right; import example.Shape; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; +import example.nontransferable.ContainerWithPackageShare; +import example.nontransferable.NoTransferBiDirectional; +import example.nontransferable.ShareableWithPackageShare; +import example.nontransferable.StrictNoTransfer; +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.junit.jupiter.api.TestInstance; +import org.mockito.ArgumentCaptor; + +import io.reactivex.Observable; import nocreate.NoCreateEntity; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.Currency; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.function.BiFunction; +import java.util.UUID; +import java.util.function.BiPredicate; +import java.util.function.Function; import java.util.stream.Collectors; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.ManyToMany; -import javax.persistence.OneToOne; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; - /** * Test PersistentResource. */ -public class PersistentResourceTest extends PersistentResource { - private final RequestScope goodUserScope; - private final RequestScope badUserScope; - private static final AuditLogger MOCK_AUDIT_LOGGER = mock(AuditLogger.class); - - public PersistentResourceTest() { - super( - new Child(), - null, - new RequestScope(null, null, null, null, new EntityDictionary(new HashMap<>()), null, MOCK_AUDIT_LOGGER) - ); - goodUserScope = new RequestScope(null, null, mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS), - new User(1), dictionary, null, MOCK_AUDIT_LOGGER); - badUserScope = new RequestScope(null, null, mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS), - new User(-1), dictionary, null, MOCK_AUDIT_LOGGER); - } - - @BeforeTest - public void init() { - dictionary.bindEntity(Child.class); - dictionary.bindEntity(Parent.class); - dictionary.bindEntity(FunWithPermissions.class); - dictionary.bindEntity(Left.class); - dictionary.bindEntity(Right.class); - dictionary.bindEntity(NoReadEntity.class); - dictionary.bindEntity(NoDeleteEntity.class); - dictionary.bindEntity(NoUpdateEntity.class); - dictionary.bindEntity(NoCreateEntity.class); - dictionary.bindEntity(NoShareEntity.class); - dictionary.bindEntity(example.User.class); - dictionary.bindEntity(FirstClassFields.class); - dictionary.bindEntity(MapColorShape.class); - dictionary.bindEntity(ChangeSpecModel.class); - dictionary.bindEntity(ChangeSpecChild.class); +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class PersistentResourceTest extends PersistenceResourceTestSetup { + + private final User goodUser = new TestUser("1"); + private final User badUser = new TestUser("-1"); + + private final DataStoreTransaction tx = mock(DataStoreTransaction.class); + + @BeforeEach + public void beforeTest() { + reset(tx); + } + + @Test + public void testUpdateToOneRelationHookInAddRelation() { + FunWithPermissions fun = new FunWithPermissions(); + Child child = newChild(1); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + funResource.addRelation("relation3", childResource); + + verify(tx, times(1)).updateToOneRelation(eq(tx), eq(fun), any(), any(), eq(goodScope)); + verify(tx, never()).updateToOneRelation(eq(tx), eq(child), any(), any(), eq(goodScope)); + } + + @Test + public void testUpdateToOneRelationHookInUpdateRelation() { + FunWithPermissions fun = new FunWithPermissions(); + Child child1 = newChild(1); + Child child2 = newChild(2); + fun.setRelation1(Sets.newHashSet(child1)); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + PersistentResource child2Resource = new PersistentResource<>(child2, "1", goodScope); + funResource.updateRelation("relation3", Sets.newHashSet(child2Resource)); + + verify(tx, times(1)).updateToOneRelation(eq(tx), eq(fun), any(), any(), eq(goodScope)); + verify(tx, never()).updateToOneRelation(eq(tx), eq(child1), any(), any(), eq(goodScope)); + verify(tx, never()).updateToOneRelation(eq(tx), eq(child2), any(), any(), eq(goodScope)); + } + + @Test + public void testUpdateToOneRelationHookInRemoveRelation() { + FunWithPermissions fun = new FunWithPermissions(); + Child child = newChild(1); + fun.setRelation3(child); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + funResource.removeRelation("relation3", childResource); + + verify(tx, times(1)).updateToOneRelation(eq(tx), eq(fun), any(), any(), eq(goodScope)); + verify(tx, never()).updateToOneRelation(eq(tx), eq(child), any(), any(), eq(goodScope)); + } + + @Test + public void testUpdateToOneRelationHookInClearRelation() { + FunWithPermissions fun = new FunWithPermissions(); + Child child1 = newChild(1); + fun.setRelation3(child1); + + when(tx.getToOneRelation(any(), eq(fun), any(), any())).thenReturn(child1); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + funResource.clearRelation("relation3"); + + verify(tx, times(1)).updateToOneRelation(eq(tx), eq(fun), any(), any(), eq(goodScope)); + verify(tx, never()).updateToOneRelation(eq(tx), eq(child1), any(), any(), eq(goodScope)); + } + + @Test + public void testUpdateToManyRelationHookInAddRelationBidirection() { + Parent parent = new Parent(); + Child child = newChild(1); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(parent, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + parentResource.addRelation("children", childResource); + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(parent), + any(), any(), any(), eq(goodScope)); + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(child), + any(), any(), any(), eq(goodScope)); + } + + @Test + public void testUpdateToManyRelationHookInRemoveRelationBidirection() { + Parent parent = new Parent(); + Child child = newChild(1); + parent.setChildren(Sets.newHashSet(child)); + child.setParents(Sets.newHashSet(parent)); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(parent, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + parentResource.removeRelation("children", childResource); + + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(parent), + any(), any(), any(), eq(goodScope)); + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(child), + any(), any(), any(), eq(goodScope)); + } + + @Test + public void testUpdateToManyRelationHookInClearRelationBidirection() { + Parent parent = new Parent(); + Child child1 = newChild(1); + Child child2 = newChild(2); + Set children = Sets.newHashSet(child1, child2); + parent.setChildren(children); + child1.setParents(Sets.newHashSet(parent)); + child2.setParents(Sets.newHashSet(parent)); + + when(tx.getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(parent, "3", goodScope); + parentResource.clearRelation("children"); + + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(parent), + any(), any(), any(), eq(goodScope)); + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(child1), + any(), any(), any(), eq(goodScope)); + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(child2), + any(), any(), any(), eq(goodScope)); + } + + @Test + public void testUpdateToManyRelationHookInUpdateRelationBidirection() { + Parent parent = new Parent(); + Child child1 = newChild(1); + Child child2 = newChild(2); + Child child3 = newChild(3); + Set children = Sets.newHashSet(child1, child2); + parent.setChildren(children); + child1.setParents(Sets.newHashSet(parent)); + child2.setParents(Sets.newHashSet(parent)); + + when(tx.getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(parent, "3", goodScope); + PersistentResource childResource1 = new PersistentResource<>(child1, "1", goodScope); + PersistentResource childResource3 = new PersistentResource<>(child3, "1", goodScope); + parentResource.updateRelation("children", Sets.newHashSet(childResource1, childResource3)); + + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(parent), + any(), any(), any(), eq(goodScope)); + verify(tx, never()).updateToManyRelation(eq(tx), eq(child1), + any(), any(), any(), eq(goodScope)); + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(child2), + any(), any(), any(), eq(goodScope)); + verify(tx, times(1)).updateToManyRelation(eq(tx), eq(child3), + any(), any(), any(), eq(goodScope)); + } + + @Test + public void testSetAttributeHookInUpdateAttribute() { + Parent parent = newParent(1); + ArgumentCaptor attributeArgument = ArgumentCaptor.forClass(Attribute.class); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); + parentResource.updateAttribute("firstName", "foobar"); + + 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 public void testGetRelationships() { FunWithPermissions fun = new FunWithPermissions(); fun.setRelation1(Sets.newHashSet()); - fun.relation2 = Sets.newHashSet(); + 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, "3", scope); Map relationships = funResource.getRelationships(); - Assert.assertEquals(relationships.size(), 5, "All relationships should be returned."); - Assert.assertTrue(relationships.containsKey("relation1"), "relation1 should be present"); - Assert.assertTrue(relationships.containsKey("relation2"), "relation2 should be present"); - Assert.assertTrue(relationships.containsKey("relation3"), "relation3 should be present"); - Assert.assertTrue(relationships.containsKey("relation4"), "relation4 should be present"); - Assert.assertTrue(relationships.containsKey("relation5"), "relation5 should be present"); + assertEquals(5, relationships.size(), "All relationships should be returned."); + assertTrue(relationships.containsKey("relation1"), "relation1 should be present"); + assertTrue(relationships.containsKey("relation2"), "relation2 should be present"); + assertTrue(relationships.containsKey("relation3"), "relation3 should be present"); + assertTrue(relationships.containsKey("relation4"), "relation4 should be present"); + assertTrue(relationships.containsKey("relation5"), "relation5 should be present"); + + scope = new TestRequestScope(tx, badUser, dictionary); - PersistentResource funResourceWithBadScope = new PersistentResource<>(fun, null, "3", badUserScope); + PersistentResource funResourceWithBadScope = new PersistentResource<>(fun, "3", scope); relationships = funResourceWithBadScope.getRelationships(); - Assert.assertEquals(relationships.size(), 0, "All relationships should be filtered out"); + assertEquals(0, relationships.size(), "All relationships should be filtered out"); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testNoCreate() { - Assert.assertNotNull(dictionary); + assertNotNull(dictionary); NoCreateEntity noCreate = new NoCreateEntity(); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - when(tx.createObject(NoCreateEntity.class)).thenReturn(noCreate); + RequestScope goodScope = buildRequestScope(tx, goodUser); + when(tx.createNewObject(ClassType.of(NoCreateEntity.class), goodScope)).thenReturn(noCreate); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource created = PersistentResource.createObject(NoCreateEntity.class, goodScope, "uuid"); + assertThrows( + ForbiddenAccessException.class, + () -> PersistentResource.createObject( + ClassType.of(NoCreateEntity.class), goodScope, Optional.of("1"))); // should throw here } @Test public void testGetAttributes() { FunWithPermissions fun = new FunWithPermissions(); - fun.field3 = "Foobar"; + fun.setField3("Foobar"); fun.setField1("blah"); fun.setField2(null); - fun.field4 = "bar"; + fun.setField4("bar"); + + when(tx.getAttribute(any(), any(), any())).thenCallRealMethod(); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource funResource = new PersistentResource<>(fun, "3", scope); Map attributes = funResource.getAttributes(); - Assert.assertEquals(attributes.size(), 6, + assertEquals(6, attributes.size(), "A valid user should have access to all attributes that are readable." ); - Assert.assertTrue(attributes.containsKey("field2"), "Readable attributes should include field2"); - Assert.assertTrue(attributes.containsKey("field3"), "Readable attributes should include field3"); - Assert.assertTrue(attributes.containsKey("field4"), "Readable attributes should include field4"); - Assert.assertTrue(attributes.containsKey("field5"), "Readable attributes should include field5"); - Assert.assertTrue(attributes.containsKey("field6"), "Readable attributes should include field6"); - Assert.assertTrue(attributes.containsKey("field8"), "Readable attributes should include field8"); - Assert.assertEquals(attributes.get("field2"), null, "field2 should be set to original value."); - Assert.assertEquals(attributes.get("field3"), "Foobar", "field3 should be set to original value."); - Assert.assertEquals(attributes.get("field4"), "bar", "field4 should be set to original value."); + assertTrue(attributes.containsKey("field2"), "Readable attributes should include field2"); + assertTrue(attributes.containsKey("field3"), "Readable attributes should include field3"); + assertTrue(attributes.containsKey("field4"), "Readable attributes should include field4"); + assertTrue(attributes.containsKey("field5"), "Readable attributes should include field5"); + assertTrue(attributes.containsKey("field6"), "Readable attributes should include field6"); + assertTrue(attributes.containsKey("field8"), "Readable attributes should include field8"); + assertNull(attributes.get("field2"), "field2 should be set to original value."); + assertEquals(attributes.get("field3"), "Foobar", "field3 should be set to original value."); + assertEquals(attributes.get("field4"), "bar", "field4 should be set to original value."); - PersistentResource funResourceBad = new PersistentResource<>(fun, null, "3", badUserScope); + RequestScope badUserScope = new TestRequestScope(tx, badUser, dictionary); + PersistentResource funResourceBad = new PersistentResource<>(fun, "3", badUserScope); attributes = funResourceBad.getAttributes(); - Assert.assertEquals(attributes.size(), 3, "An invalid user should have access to a subset of attributes."); - Assert.assertTrue(attributes.containsKey("field2"), "Readable attributes should include field2"); - Assert.assertTrue(attributes.containsKey("field4"), "Readable attributes should include field4"); - Assert.assertTrue(attributes.containsKey("field5"), "Readable attributes should include field5"); - Assert.assertEquals(attributes.get("field2"), null, "field2 should be set to original value."); - Assert.assertEquals(attributes.get("field4"), "bar", "field4 should be set to original value."); + assertEquals(3, attributes.size(), "An invalid user should have access to a subset of attributes."); + assertTrue(attributes.containsKey("field2"), "Readable attributes should include field2"); + assertTrue(attributes.containsKey("field4"), "Readable attributes should include field4"); + assertTrue(attributes.containsKey("field5"), "Readable attributes should include field5"); + assertNull(attributes.get("field2"), "field2 should be set to original value."); + assertEquals(attributes.get("field4"), "bar", "field4 should be set to original value."); } @Test @@ -207,63 +388,83 @@ 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); - - Set> resources = - Sets.newHashSet(child1Resource, child2Resource, child3Resource, child4Resource); - - Set> results = PersistentResource.filter(ReadPermission.class, resources); - Assert.assertEquals(results.size(), 2, "Only a subset of the children are readable"); - Assert.assertTrue(results.contains(child1Resource), "Readable children includes children with positive IDs"); - Assert.assertTrue(results.contains(child3Resource), "Readable children includes children with positive IDs"); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource child1Resource = new PersistentResource<>(child1, "1", scope); + PersistentResource child2Resource = new PersistentResource<>(child2, "-2", scope); + PersistentResource child3Resource = new PersistentResource<>(child3, "3", scope); + PersistentResource child4Resource = new PersistentResource<>(child4, "-4", scope); + + Observable resources = + Observable.fromArray(child1Resource, child2Resource, child3Resource, child4Resource); + + Set results = + PersistentResource.filter( + ReadPermission.class, + Optional.empty(), ALL_FIELDS, resources).toList(LinkedHashSet::new).blockingGet(); + + assertEquals(2, results.size(), "Only a subset of the children are readable"); + assertTrue(results.contains(child1Resource), "Readable children includes children with positive IDs"); + assertTrue(results.contains(child3Resource), "Readable children includes children with positive IDs"); } { - 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, "1", scope); + PersistentResource child2Resource = new PersistentResource<>(child2, "-2", scope); + PersistentResource child3Resource = new PersistentResource<>(child3, "3", scope); + PersistentResource child4Resource = new PersistentResource<>(child4, "-4", scope); - Set> resources = - Sets.newHashSet(child1Resource, child2Resource, child3Resource, child4Resource); + Observable resources = + Observable.fromArray(child1Resource, child2Resource, child3Resource, child4Resource); - Set> results = PersistentResource.filter(ReadPermission.class, resources); - Assert.assertEquals(results.size(), 0, "No children are readable by an invalid user"); + Set results = PersistentResource.filter(ReadPermission.class, + Optional.empty(), ALL_FIELDS, resources).toList(LinkedHashSet::new).blockingGet(); + + assertEquals(0, results.size(), "No children are readable by an invalid user"); } } @Test public void testGetValue() throws Exception { FunWithPermissions fun = new FunWithPermissions(); - fun.field3 = "testValue"; + fun.setField3("testValue"); String result; - result = (String) getValue(fun, "field3", dictionary); - Assert.assertEquals(result, "testValue", "getValue should set the appropriate value in the resource"); + result = (String) getValue(fun, "field3", getRequestScope()); + assertEquals("testValue", result, "getValue should set the appropriate value in the resource"); fun.setField1("testValue2"); - result = (String) getValue(fun, "field1", dictionary); - Assert.assertEquals(result, "testValue2", "getValue should set the appropriate value in the resource"); + result = (String) getValue(fun, "field1", getRequestScope()); + assertEquals(result, "testValue2", "getValue should set the appropriate value in the resource"); Child testChild = newChild(3); fun.setRelation1(Sets.newHashSet(testChild)); @SuppressWarnings("unchecked") - Set children = (Set) getValue(fun, "relation1", dictionary); + Set children = (Set) getValue(fun, "relation1", getRequestScope()); - Assert.assertTrue(children.contains(testChild), "getValue should set the correct relation."); - Assert.assertEquals(children.size(), 1, "getValue should set the relation with the correct number of elements"); + assertTrue(children.contains(testChild), "getValue should set the correct relation."); + assertEquals(1, children.size(), "getValue should set the relation with the correct number of elements"); - try { - getValue(fun, "badRelation", dictionary); - } catch (InvalidAttributeException e) { - return; - } + ComputedBean computedBean = new ComputedBean(); + + String computedTest1 = (String) getValue(computedBean, "test", getRequestScope()); + String computedTest2 = (String) getValue(computedBean, "testWithScope", getRequestScope()); + String computedTest3 = (String) getValue(computedBean, "testWithSecurityScope", getRequestScope()); + + assertEquals("test1", computedTest1); + assertEquals("test2", computedTest2); + assertEquals("test3", computedTest3); + + assertThrows( + InvalidAttributeException.class, + () -> getValue(computedBean, "NonComputedWithScope", getRequestScope()), + "Getting a bad relation should throw an InvalidAttributeException."); - Assert.fail("Getting a bad relation should throw an InvalidAttributeException."); + assertThrows( + InvalidAttributeException.class, + () -> setValue("badRelation", "badValue"), + "Getting a bad relation should throw an InvalidAttributeException."); } @Test @@ -271,23 +472,23 @@ public void testSetValue() throws Exception { FunWithPermissions fun = new FunWithPermissions(); this.obj = fun; setValue("field3", "testValue"); - Assert.assertEquals(fun.field3, "testValue", "setValue should set the appropriate value in the resource"); + assertEquals("testValue", fun.getField3(), "setValue should set the appropriate value in the resource"); setValue("field1", "testValue2"); - Assert.assertEquals(fun.getField1(), "testValue2", "setValue should set the appropriate value in the resource"); + assertEquals("testValue2", fun.getField1(), "setValue should set the appropriate value in the resource"); Child testChild = newChild(3); setValue("relation1", Sets.newHashSet(testChild)); - Assert.assertTrue(fun.getRelation1().contains(testChild), "setValue should set the correct relation."); - Assert.assertEquals(fun.getRelation1().size(), 1, "setValue should set the relation with the correct number of elements"); - - try { - setValue("badRelation", "badValue"); - } catch (InvalidAttributeException e) { - return; - } - Assert.fail("Setting a bad relation should throw an InvalidAttributeException."); + assertTrue(fun.getRelation1().contains(testChild), "setValue should set the correct relation."); + assertEquals( + fun.getRelation1().size(), + 1, + "setValue should set the relation with the correct number of elements"); + assertThrows( + InvalidAttributeException.class, + () -> setValue("badRelation", "badValue"), + "Getting a bad relation should throw an InvalidAttributeException."); } @Test @@ -301,30 +502,28 @@ public void testSetMapValue() { coerceable.put("Violet", "Triangle"); setValue("colorShapeMap", coerceable); - Assert.assertEquals(mapColorShape.getColorShapeMap().get(Color.Red), Shape.Circle); - Assert.assertEquals(mapColorShape.getColorShapeMap().get(Color.Green), Shape.Square); - Assert.assertEquals(mapColorShape.getColorShapeMap().get(Color.Violet), Shape.Triangle); - Assert.assertEquals(mapColorShape.getColorShapeMap().size(), 3); + assertEquals(Shape.Circle, mapColorShape.getColorShapeMap().get(Color.Red)); + assertEquals(Shape.Square, mapColorShape.getColorShapeMap().get(Color.Green)); + assertEquals(Shape.Triangle, mapColorShape.getColorShapeMap().get(Color.Violet)); + assertEquals(3, mapColorShape.getColorShapeMap().size()); } - @Test(expectedExceptions = {InvalidValueException.class}) + @Test public void testSetMapInvalidColorEnum() { - MapColorShape mapColorShape = new MapColorShape(); - this.obj = mapColorShape; + this.obj = new MapColorShape(); HashMap coerceable = new HashMap<>(); coerceable.put("InvalidColor", "Circle"); - setValue("colorShapeMap", coerceable); + assertThrows(InvalidValueException.class, () -> setValue("colorShapeMap", coerceable)); } - @Test(expectedExceptions = {InvalidValueException.class}) + @Test public void testSetMapInvalidShapeEnum() { - MapColorShape mapColorShape = new MapColorShape(); - this.obj = mapColorShape; + this.obj = new MapColorShape(); HashMap coerceable = new HashMap<>(); coerceable.put("Red", "InvalidShape"); - setValue("colorShapeMap", coerceable); + assertThrows(InvalidValueException.class, () -> setValue("colorShapeMap", coerceable)); } @Test @@ -334,12 +533,13 @@ 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, "3", scope); leftResource.deleteInverseRelation("one2one", right); - Assert.assertEquals(right.getOne2one(), null, "The one-2-one inverse relationship should have been unset"); - Assert.assertEquals(left.getOne2one(), right, "The owning relationship should NOT have been unset"); + assertNull(right.getOne2one(), "The one-2-one inverse relationship should have been unset"); + assertEquals(right, left.getOne2one(), "The owning relationship should NOT have been unset"); Child child = new Child(); Parent parent = new Parent(); @@ -347,12 +547,13 @@ 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, "4", scope); childResource.deleteInverseRelation("parents", parent); - Assert.assertEquals(parent.getChildren().size(), 0, "The many-2-many inverse collection should have been cleared."); - Assert.assertTrue(child.getParents().contains(parent), "The owning relationship should NOT have been touched"); + assertEquals(parent.getChildren().size(), 0, "The many-2-many inverse collection should have been cleared."); + assertTrue(child.getParents().contains(parent), "The owning relationship should NOT have been touched"); } @Test @@ -360,11 +561,12 @@ 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, "3", scope); leftResource.addInverseRelation("one2one", right); - Assert.assertEquals(right.getOne2one(), left, "The one-2-one inverse relationship should have been updated."); + assertEquals(left, right.getOne2one(), "The one-2-one inverse relationship should have been updated."); Child child = new Child(); Parent parent = new Parent(); @@ -372,41 +574,79 @@ 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, "4", scope); childResource.addInverseRelation("parents", parent); - Assert.assertEquals(parent.getChildren().size(), 1, "The many-2-many inverse relationship should have been updated"); - Assert.assertTrue(parent.getChildren().contains(child), "The many-2-many inverse relationship should have been updated"); + assertEquals( + 1, + parent.getChildren().size(), + "The many-2-many inverse relationship should have been updated"); + assertTrue( + parent.getChildren().contains(child), + "The many-2-many inverse relationship should have been updated"); } @Test public void testSuccessfulOneToOneRelationshipAdd() throws Exception { - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); Left left = new Left(); Right right = new Right(); left.setId(2); right.setId(3); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + RequestScope goodScope = buildRequestScope(tx, goodUser); - PersistentResource leftResource = new PersistentResource<>(left, null, "2", goodScope); + PersistentResource leftResource = new PersistentResource<>(left, "2", goodScope); Relationship ids = new Relationship(null, new Data<>(new ResourceIdentifier("right", "3").castToResource())); - when(tx.loadObject(Right.class, 3L)).thenReturn(right); + when(tx.loadObject(any(), eq(3L), any())).thenReturn(right); boolean updated = leftResource.updateRelation("one2one", ids.toPersistentResources(goodScope)); - goodScope.saveObjects(); - verify(tx, times(1)).save(left); - verify(tx, times(1)).save(right); + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(left, goodScope); + verify(tx, times(1)).save(right, goodScope); + verify(tx, times(1)).getToOneRelation(tx, left, getRelationship(ClassType.of(Right.class), "one2one"), goodScope); - Assert.assertEquals(updated, true, "The one-2-one relationship should be added."); - Assert.assertEquals(left.getOne2one().getId(), 3, "The correct object was set in the one-2-one relationship"); + 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"); } - @Test /** + * Avoid NPE when PATCH or POST defines relationship with null id + *
+     * 
+     * "relationships": {
+     *   "left": {
+     *     "data": {
+     *       "type": "right",
+     *       "id": null
+     *     }
+     *   }
+     * }
+     * 
+     * 
+ */ + @Test + public void testSuccessfulOneToOneRelationshipAddNull() throws Exception { + Left left = new Left(); + left.setId(2); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + + PersistentResource leftResource = new PersistentResource<>(left, "2", goodScope); + + Relationship ids = new Relationship(null, new Data<>(new Resource("right", null, null, null, null, null))); + + InvalidObjectIdentifierException thrown = assertThrows( + InvalidObjectIdentifierException.class, + () -> leftResource.updateRelation("one2one", ids.toPersistentResources(goodScope))); + + assertEquals("Unknown identifier null for right", thrown.getMessage()); + } + + @Test + /* * The following are ids for a hypothetical relationship. * GIVEN: * all (all the ids in the DB) = 1,2,3,4,5 @@ -422,10 +662,8 @@ public void testSuccessfulOneToOneRelationshipAdd() throws Exception { * final = (notMine) UNION requested */ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); Parent parent = new Parent(); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + RequestScope goodScope = buildRequestScope(tx, goodUser); Child child1 = newChild(1); Child child2 = newChild(2); @@ -445,7 +683,9 @@ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { parent.setChildren(allChildren); parent.setSpouses(Sets.newHashSet()); - PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); + when(tx.getToManyRelation(any(), eq(parent), any(), any())).thenReturn(new DataStoreIterableBuilder(allChildren).build()); + + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); //Requested = (3,6) List idList = new ArrayList<>(); @@ -453,12 +693,11 @@ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { idList.add(new ResourceIdentifier("child", "6").castToResource()); Relationship ids = new Relationship(null, new Data<>(idList)); - - when(tx.loadObject(Child.class, 2L)).thenReturn(child2); - when(tx.loadObject(Child.class, 3L)).thenReturn(child3); - when(tx.loadObject(Child.class, -4L)).thenReturn(child4); - when(tx.loadObject(Child.class, -5L)).thenReturn(child5); - when(tx.loadObject(Child.class, 6L)).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<>(); @@ -469,18 +708,100 @@ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { boolean updated = parentResource.updateRelation("children", ids.toPersistentResources(goodScope)); - goodScope.saveObjects(); - verify(tx, times(1)).save(parent); - verify(tx, times(1)).save(child1); - verify(tx, times(1)).save(child2); - verify(tx, times(1)).save(child6); - verify(tx, never()).save(child4); - verify(tx, never()).save(child5); - verify(tx, never()).save(child3); + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(parent, goodScope); + verify(tx, times(1)).save(child1, goodScope); + verify(tx, times(1)).save(child2, goodScope); + verify(tx, times(1)).save(child6, goodScope); + verify(tx, never()).save(child4, goodScope); + verify(tx, never()).save(child5, goodScope); + verify(tx, never()).save(child3, goodScope); + + assertTrue(updated, "Many-2-many relationship should be updated."); + assertTrue(parent.getChildren().containsAll(expected), "All expected members were updated"); + assertTrue(expected.containsAll(parent.getChildren()), "All expected members were updated"); + + /* + * No tests for reference integrity since the parent is the owner and + * this is a many to many relationship. + */ + } + + @Test + /* + * The following are ids for a hypothetical relationship. + * GIVEN: + * all (all the ids in the DB) = 1,2,3,4,5 + * mine (everything the current user has access to) = 1,2,3 + * requested (what the user wants to change to) = 1,2,3 + * THEN: + * deleted (what gets removed from the DB) = nothing + * final (what get stored in the relationship) = 1,2,3,4,5 + * BECAUSE: + * notMine = all - mine + * updated = (requested UNION mine) - (requested INTERSECT mine) + * deleted = (mine - requested) + * final = (notMine) UNION requested + */ + public void testSuccessfulManyToManyRelationshipNoopUpdate() throws Exception { + Parent parent = new Parent(); + RequestScope goodScope = buildRequestScope(tx, goodUser); + + Child child1 = newChild(1); + Child child2 = newChild(2); + Child child3 = newChild(3); + Child child4 = newChild(-4); //Not accessible to goodUser + Child child5 = newChild(-5); //Not accessible to goodUser + + //All = (1,2,3,4,5) + //Mine = (1,2,3) + Set allChildren = new HashSet<>(); + allChildren.add(child1); + allChildren.add(child2); + allChildren.add(child3); + allChildren.add(child4); + allChildren.add(child5); + parent.setChildren(allChildren); + parent.setSpouses(Sets.newHashSet()); + + when(tx.getToManyRelation(any(), eq(parent), any(), any())).thenReturn(new DataStoreIterableBuilder(allChildren).build()); + + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); + + //Requested = (1,2,3) + List idList = new ArrayList<>(); + idList.add(new ResourceIdentifier("child", "3").castToResource()); + idList.add(new ResourceIdentifier("child", "2").castToResource()); + idList.add(new ResourceIdentifier("child", "1").castToResource()); + Relationship ids = new Relationship(null, new Data<>(idList)); + + when(tx.loadObject(any(), eq(1L), any())).thenReturn(child1); + 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); + + //Final set after operation = (1,2,3,4,5) + Set expected = new HashSet<>(); + expected.add(child1); + expected.add(child2); + expected.add(child3); + expected.add(child4); + expected.add(child5); + + boolean updated = parentResource.updateRelation("children", ids.toPersistentResources(goodScope)); + + goodScope.saveOrCreateObjects(); + verify(tx, never()).save(parent, goodScope); + verify(tx, never()).save(child1, goodScope); + verify(tx, never()).save(child2, goodScope); + verify(tx, never()).save(child4, goodScope); + verify(tx, never()).save(child5, goodScope); + verify(tx, never()).save(child3, goodScope); - Assert.assertEquals(updated, true, "Many-2-many relationship should be updated."); - Assert.assertTrue(parent.getChildren().containsAll(expected), "All expected members were updated"); - Assert.assertTrue(expected.containsAll(parent.getChildren()), "All expected members were updated"); + assertFalse(updated, "Many-2-many relationship should not be updated."); + assertTrue(parent.getChildren().containsAll(expected), "All expected members were updated"); + assertTrue(expected.containsAll(parent.getChildren()), "All expected members were updated"); /* * No tests for reference integrity since the parent is the owner and @@ -488,46 +809,172 @@ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { */ } + @Test + /* + * The following are ids for a hypothetical relationship. + * GIVEN: + * all (all the ids in the DB) = null + * mine (everything the current user has access to) = null + * requested (what the user wants to change to) = 1,2,3 + * THEN: + * deleted (what gets removed from the DB) = nothing + * final (what get stored in the relationship) = 1,2,3 + * BECAUSE: + * notMine = all - mine + * updated = (requested UNION mine) - (requested INTERSECT mine) + * deleted = (mine - requested) + * final = (notMine) UNION requested + */ + public void testSuccessfulManyToManyRelationshipNullUpdate() throws Exception { + Parent parent = new Parent(); + RequestScope goodScope = buildRequestScope(tx, goodUser); + + Child child1 = newChild(1); + Child child2 = newChild(2); + Child child3 = newChild(3); + + //All = null + //Mine = null + Set allChildren = new HashSet<>(); + allChildren.add(child1); + allChildren.add(child2); + allChildren.add(child3); + parent.setChildren(null); + parent.setSpouses(Sets.newHashSet()); + + when(tx.getToManyRelation(any(), eq(parent), any(), any())).thenReturn(null); + + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); + + //Requested = (1,2,3) + List idList = new ArrayList<>(); + idList.add(new ResourceIdentifier("child", "3").castToResource()); + idList.add(new ResourceIdentifier("child", "2").castToResource()); + idList.add(new ResourceIdentifier("child", "1").castToResource()); + Relationship ids = new Relationship(null, new Data<>(idList)); + + when(tx.loadObject(any(), eq(1L), any())).thenReturn(child1); + when(tx.loadObject(any(), eq(2L), any())).thenReturn(child2); + when(tx.loadObject(any(), eq(3L), any())).thenReturn(child3); + + //Final set after operation = (1,2,3) + Set expected = new HashSet<>(); + expected.add(child1); + expected.add(child2); + expected.add(child3); + + boolean updated = parentResource.updateRelation("children", ids.toPersistentResources(goodScope)); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(parent, goodScope); + verify(tx, times(1)).save(child1, goodScope); + verify(tx, times(1)).save(child2, goodScope); + verify(tx, times(1)).save(child3, goodScope); + + assertTrue(updated, "Many-2-many relationship should be updated."); + assertTrue(parent.getChildren().containsAll(expected), "All expected members were updated"); + assertTrue(expected.containsAll(parent.getChildren()), "All expected members were updated"); + + /* + * No tests for reference integrity since the parent is the owner and + * this is a many to many relationship. + */ + } + + /** + * Verify that Relationship toMany cannot contain null resources, but toOne can. + * + * @throws Exception + */ + @Test + public void testRelationshipMissingData() throws Exception { + User goodUser = new TestUser("1"); + + @SuppressWarnings("resource") + DataStoreTransaction tx = mock(DataStoreTransaction.class); + + RequestScope goodScope = new RequestScope( + null, + null, + NO_VERSION, + null, + tx, + goodUser, + null, + null, + UUID.randomUUID(), + elideSettings); + + // null resource in toMany relationship is not valid + List idList = new ArrayList<>(); + idList.add(new ResourceIdentifier("child", "3").castToResource()); + idList.add(new ResourceIdentifier("child", "6").castToResource()); + idList.add(null); + assertThrows( + NullPointerException.class, + () -> new Relationship(Collections.emptyMap(), new Data<>(idList))); + + // However null toOne relationship is valid + Relationship toOneRelationship = new Relationship(Collections.emptyMap(), new Data<>((Resource) null)); + assertTrue(toOneRelationship.getData().get().isEmpty()); + assertNull(toOneRelationship.toPersistentResources(goodScope)); + + // no Data + Relationship nullRelationship = new Relationship(Collections.emptyMap(), null); + assertNull(nullRelationship.getData()); + assertNull(nullRelationship.toPersistentResources(goodScope)); + } + @Test public void testGetAttributeSuccess() { FunWithPermissions fun = new FunWithPermissions(); fun.setField2("blah"); - fun.field3 = null; + fun.setField3(null); + + when(tx.getAttribute(any(), any(), any())).thenCallRealMethod(); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + + PersistentResource funResource = new PersistentResource<>(fun, "1", scope); String result = (String) funResource.getAttribute("field2"); - Assert.assertEquals(result, "blah", "The correct attribute should be returned."); + assertEquals("blah", result, "The correct attribute should be returned."); result = (String) funResource.getAttribute("field3"); - Assert.assertEquals(result, null, "The correct attribute should be returned."); + assertNull(result, "The correct attribute should be returned."); } - @Test(expectedExceptions = InvalidAttributeException.class) + @Test public void testGetAttributeInvalidField() { FunWithPermissions fun = new FunWithPermissions(); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); - funResource.getAttribute("invalid"); + PersistentResource funResource = new PersistentResource<>(fun, "1", scope); + + assertThrows(InvalidAttributeException.class, () -> funResource.getAttribute("invalid")); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test 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); - funResource.getAttribute("field1"); + PersistentResource funResource = new PersistentResource<>(fun, "1", scope); + + assertThrows(ForbiddenAccessException.class, () -> funResource.getAttribute("field1")); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testGetAttributeInvalidEntityPermissions() { NoReadEntity noread = new NoReadEntity(); - PersistentResource noreadResource = new PersistentResource<>(noread, null, "1", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); - noreadResource.getAttribute("field"); + PersistentResource noreadResource = new PersistentResource<>(noread, "1", scope); + + assertThrows(ForbiddenAccessException.class, () -> noreadResource.getAttribute("field")); } @Test @@ -536,13 +983,18 @@ public void testGetRelationSuccess() { Child child1 = newChild(1); Child child2 = newChild(2); Child child3 = newChild(3); - fun.relation2 = Sets.newHashSet(child1, child2, child3); + Set children = Sets.newHashSet(child1, child2, child3); + fun.setRelation2(children); + + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource funResource = new PersistentResource<>(fun, "3", scope); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + when(scope.getTransaction().getToManyRelation(any(), eq(fun), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); - Set results = funResource.getRelationCheckedFiltered("relation2"); + Set results = getRelation(funResource, "relation2"); - Assert.assertEquals(results.size(), 3, "All of relation elements should be returned."); + assertEquals(3, results.size(), "All of relation elements should be returned."); } @Test @@ -551,13 +1003,19 @@ public void testGetRelationFilteredSuccess() { Child child1 = newChild(1); Child child2 = newChild(-2); Child child3 = newChild(3); - fun.relation2 = Sets.newHashSet(child1, child2, child3); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + Set children = Sets.newHashSet(child1, child2, child3); + fun.setRelation2(Sets.newHashSet(child1, child2, child3)); - Set results = funResource.getRelationCheckedFiltered("relation2"); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource funResource = new PersistentResource<>(fun, "3", scope); - Assert.assertEquals(results.size(), 2, "Only filtered relation elements should be returned."); + when(scope.getTransaction().getToManyRelation(any(), eq(fun), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); + + Set results = getRelation(funResource, "relation2"); + + assertEquals(2, results.size(), "Only filtered relation elements should be returned."); } @Test @@ -568,84 +1026,119 @@ public void testGetRelationWithPredicateSuccess() { Child child3 = newChild(3, "chris smith"); parent.setChildren(Sets.newHashSet(child1, child2, child3)); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), any(), any(), any(), any(), anySet())).thenReturn(Sets.newHashSet(child1)); - User goodUser = new User(1); + when(tx.getToManyRelation(eq(tx), any(), any(), any())) + .thenReturn(new DataStoreIterableBuilder(Sets.newHashSet(child1)).build()); MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.add("filter[child.name]", "paul john"); - RequestScope goodScope = new RequestScope( - "/child", null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER, queryParams); + RequestScope goodScope = buildRequestScope("/child", tx, goodUser, queryParams); - PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); - Set results = parentResource.getRelationCheckedFiltered("children"); + Set results = getRelation(parentResource, "children"); - Assert.assertEquals(results.size(), 1); - Assert.assertEquals(((Child) results.iterator().next().getObject()).getName(), "paul john"); + assertEquals(1, results.size()); + assertEquals("paul john", ((Child) IterableUtils.first(results).getObject()).getName()); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test + public void testGetSingleRelationInMemory() { + // Ensure we don't break when we try to get a specific relationship in memory (i.e. not yet pushed to datastore) + Parent parent = newParent(1); + Child child1 = newChild(1, "paul john"); + Child child2 = newChild(2, "john buzzard"); + Child child3 = newChild(3, "chris smith"); + Set children = Sets.newHashSet(child1, child2, child3); + parent.setChildren(children); + + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + when(scope.getTransaction().getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); + + PersistentResource parentResource = new PersistentResource<>(parent, "1", scope); + + PersistentResource childResource = + parentResource.getRelation(getRelationship(ClassType.of(Parent.class), "children"), "2"); + + assertEquals("2", childResource.getId()); + assertEquals("john buzzard", ((Child) childResource.getObject()).getName()); + } + + @Test public void testGetRelationForbiddenByEntity() { NoReadEntity noread = new NoReadEntity(); - PersistentResource noreadResource = new PersistentResource<>(noread, null, "3", goodUserScope); - noreadResource.getRelationCheckedFiltered("child"); + RequestScope scope = new TestRequestScope(tx, badUser, dictionary); + + PersistentResource noreadResource = new PersistentResource<>(noread, "3", scope); + assertThrows(ForbiddenAccessException.class, () -> getRelation(noreadResource, "child")); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test 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, "3", scope); - funResource.getRelationCheckedFiltered("relation1"); + assertThrows(ForbiddenAccessException.class, () -> getRelation(funResource, "relation1")); } @Test public void testGetRelationForbiddenByEntityAllowedByField() { FirstClassFields firstClassFields = new FirstClassFields(); - PersistentResource fcResource = new PersistentResource<>(firstClassFields, null, "3", badUserScope); + RequestScope badUserScope = new TestRequestScope(tx, badUser, dictionary); + + PersistentResource fcResource = new PersistentResource<>(firstClassFields, "3", badUserScope); - fcResource.getRelationCheckedFiltered("public2"); + getRelation(fcResource, "public2"); } @Test public void testGetAttributeForbiddenByEntityAllowedByField() { FirstClassFields firstClassFields = new FirstClassFields(); - PersistentResource fcResource = new PersistentResource<>(firstClassFields, null, "3", badUserScope); + RequestScope badUserScope = new TestRequestScope(tx, badUser, dictionary); + + PersistentResource fcResource = new PersistentResource<>(firstClassFields, "3", badUserScope); fcResource.getAttribute("public1"); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testGetRelationForbiddenByEntity2() { FirstClassFields firstClassFields = new FirstClassFields(); - PersistentResource fcResource = new PersistentResource<>(firstClassFields, null, "3", badUserScope); + RequestScope badUserScope = new TestRequestScope(tx, badUser, dictionary); - fcResource.getRelationCheckedFiltered("private2"); + PersistentResource fcResource = new PersistentResource<>(firstClassFields, "3", badUserScope); + + assertThrows(ForbiddenAccessException.class, () -> getRelation(fcResource, "private2")); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testGetAttributeForbiddenByEntity2() { FirstClassFields firstClassFields = new FirstClassFields(); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); + PersistentResource fcResource = new PersistentResource<>(firstClassFields, - null, "3", goodUserScope); + "3", scope); - fcResource.getAttribute("private1"); + assertThrows(ForbiddenAccessException.class, () -> fcResource.getAttribute("private1")); } - @Test(expectedExceptions = InvalidAttributeException.class) + @Test public void testGetRelationInvalidRelation() { FunWithPermissions fun = new FunWithPermissions(); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodUserScope); + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); - funResource.getRelationCheckedFiltered("invalid"); + PersistentResource funResource = new PersistentResource<>(fun, "3", scope); + + assertThrows(InvalidAttributeException.class, () -> getRelation(funResource, "invalid")); } @Test @@ -654,73 +1147,100 @@ public void testGetRelationByIdSuccess() { Child child1 = newChild(1); Child child2 = newChild(2); Child child3 = newChild(3); - fun.relation2 = Sets.newHashSet(child1, child2, child3); - - User goodUser = new User(1); + fun.setRelation2(Sets.newHashSet(child1, child2, child3)); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), any(), any(), any(), any(), anySet())).thenReturn(Sets.newHashSet(child1)); + when(tx.getToManyRelation(eq(tx), any(), any(), any())) + .thenReturn(new DataStoreIterableBuilder(Sets.newHashSet(child1)).build()); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); - PersistentResource result = funResource.getRelation("relation2", "1"); + PersistentResource result = funResource.getRelation(getRelationship( + ClassType.of(FunWithPermissions.class), + "relation2"), + "1"); - Assert.assertEquals(((Child) result.getObject()).getId(), 1, "The correct relationship element should be returned"); + assertEquals(1, + ((Child) result.getObject()).getId(), "The correct relationship element should be returned"); } - @Test(expectedExceptions = InvalidObjectIdentifierException.class) + @Test public void testGetRelationByInvalidId() { FunWithPermissions fun = new FunWithPermissions(); Child child1 = newChild(1); Child child2 = newChild(2); Child child3 = newChild(3); - fun.relation2 = Sets.newHashSet(child1, child2, child3); + fun.setRelation2(Sets.newHashSet(child1, child2, child3)); - User goodUser = new User(1); + when(tx.getToManyRelation(eq(tx), any(), any(), any())) + .thenReturn(new DataStoreIterableBuilder(Sets.newHashSet(child1)).build()); - DataStoreTransaction tx = mock(DataStoreTransaction.class); - when(tx.getRelation(any(), any(), any(), any(), any(), anySet())).thenReturn(Sets.newHashSet(child1)); - - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); - funResource.getRelation("relation2", "-1000"); + assertThrows(InvalidObjectIdentifierException.class, + () -> funResource.getRelation( + getRelationship(ClassType.of(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); - Set set = funResource.getRelationCheckedFiltered("relation4"); - Assert.assertEquals(0, set.size()); + PersistentResource funResource = new PersistentResource<>(fun, "3", scope); + + Set set = getRelation(funResource, "relation4"); + assertEquals(0, set.size()); } @Test 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, "3", scope); - Set set = funResource.getRelationCheckedFiltered("relation5"); - Assert.assertEquals(0, set.size()); + Set set = getRelation(funResource, "relation5"); + assertEquals(0, set.size()); } @Test void testDeleteResourceSuccess() { Parent parent = newParent(1); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + RequestScope goodScope = buildRequestScope(tx, goodUser); - PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); parentResource.deleteResource(); - verify(tx).delete(parent); + verify(tx).delete(parent, goodScope); + } + + @Test + void testDeleteCascades() { + Invoice invoice = new Invoice(); + invoice.setId(1); + + LineItem item = new LineItem(); + invoice.setItems(Sets.newHashSet(item)); + item.setInvoice(invoice); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + + PersistentResource invoiceResource = new PersistentResource<>(invoice, "1", goodScope); + + invoiceResource.deleteResource(); + + verify(tx).delete(invoice, goodScope); + + /* The inverse relation should not be touched for cascading deletes */ + verify(tx, never()).save(item, goodScope); + assertEquals(1, invoice.getItems().size()); } @Test @@ -729,41 +1249,40 @@ void testDeleteResourceUpdateRelationshipSuccess() { Child child = newChild(100); parent.setChildren(Sets.newHashSet(child)); parent.setSpouses(Sets.newHashSet()); + + Set parents = Sets.newHashSet(parent); child.setParents(Sets.newHashSet(parent)); - Assert.assertFalse(parent.getChildren().isEmpty()); + assertFalse(parent.getChildren().isEmpty()); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + when(tx.getToManyRelation(any(), eq(child), any(), any())) + .thenReturn(new DataStoreIterableBuilder(parents).build()); - PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); - PersistentResource childResource = new PersistentResource<>(child, parentResource, "1", goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); - childResource.deleteResource(); + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); + PersistentResource childResource = + new PersistentResource<>(child, parentResource, "children", "1", goodScope); - goodScope.saveObjects(); - verify(tx, times(1)).delete(child); - verify(tx, times(1)).save(parent); - verify(tx, never()).delete(parent); - Assert.assertTrue(parent.getChildren().isEmpty()); + childResource.deleteResource(); + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).delete(child, goodScope); + verify(tx, times(1)).save(parent, goodScope); + verify(tx, never()).delete(parent, goodScope); + assertTrue(parent.getChildren().isEmpty()); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test void testDeleteResourceForbidden() { NoDeleteEntity nodelete = new NoDeleteEntity(); nodelete.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource nodeleteResource = new PersistentResource<>(nodelete, "1", goodScope); - PersistentResource nodeleteResource = new PersistentResource<>(nodelete, null, "1", goodScope); + assertThrows(ForbiddenAccessException.class, nodeleteResource::deleteResource); - nodeleteResource.deleteResource(); - - verify(tx, never()).delete(nodelete); + verify(tx, never()).delete(nodelete, goodScope); } @Test @@ -773,68 +1292,81 @@ void testAddRelationSuccess() { Child child = newChild(1); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); - PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); funResource.addRelation("relation1", childResource); - goodScope.saveObjects(); - verify(tx, never()).save(child); // Child wasn't modified - verify(tx, times(1)).save(fun); + goodScope.saveOrCreateObjects(); + verify(tx, never()).save(child, goodScope); // Child wasn't modified + verify(tx, times(1)).save(fun, goodScope); - Assert.assertTrue(fun.getRelation1().contains(child), "The correct element should be added to the relation"); + assertTrue(fun.getRelation1().contains(child), "The correct element should be added to the relation"); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test void testAddRelationForbiddenByField() { FunWithPermissions fun = new FunWithPermissions(); fun.setRelation1(Sets.newHashSet()); Child child = newChild(1); - User badUser = new User(-1); + RequestScope badScope = buildRequestScope(tx, badUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", badScope); + PersistentResource childResource = new PersistentResource<>(child, "1", badScope); + assertThrows(ForbiddenAccessException.class, () -> funResource.addRelation("relation1", childResource)); + } - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - RequestScope badScope = new RequestScope(null, null, tx, badUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", badScope); - PersistentResource childResource = new PersistentResource<>(child, null, "1", badScope); - funResource.addRelation("relation1", childResource); + @Test + void testAddRelationForbiddenByToManyExistingRelationship() { + FunWithPermissions fun = new FunWithPermissions(); + + Child child = newChild(1); + fun.setRelation1(Set.of(child)); + + RequestScope badScope = buildRequestScope(tx, badUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", badScope); + PersistentResource childResource = new PersistentResource<>(child, "1", badScope); + assertThrows(ForbiddenAccessException.class, () -> funResource.addRelation("relation1", childResource)); + } + + @Test + void testAddRelationForbiddenByToOneExistingRelationship() { + FunWithPermissions fun = new FunWithPermissions(); + + Child child = newChild(1); + fun.setRelation3(child); + + RequestScope badScope = buildRequestScope(tx, badUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", badScope); + PersistentResource childResource = new PersistentResource<>(child, "1", badScope); + assertThrows(ForbiddenAccessException.class, () -> funResource.addRelation("relation3", childResource)); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test void testAddRelationForbiddenByEntity() { NoUpdateEntity noUpdate = new NoUpdateEntity(); noUpdate.setId(1); Child child = newChild(2); - noUpdate.children = Sets.newHashSet(); - - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); + noUpdate.setChildren(Sets.newHashSet()); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource noUpdateResource = new PersistentResource<>(noUpdate, null, "1", goodScope); - PersistentResource childResource = new PersistentResource<>(child, null, "2", goodScope); - noUpdateResource.addRelation("children", childResource); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource noUpdateResource = new PersistentResource<>(noUpdate, "1", goodScope); + PersistentResource childResource = new PersistentResource<>(child, "2", goodScope); + assertThrows(ForbiddenAccessException.class, () -> noUpdateResource.addRelation("children", childResource)); } - @Test(expectedExceptions = InvalidAttributeException.class) + @Test public void testAddRelationInvalidRelation() { FunWithPermissions fun = new FunWithPermissions(); Child child = newChild(1); - User goodUser = new User(1); - - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource funResource = new PersistentResource<>(fun, null, "3", goodScope); - PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); - funResource.addRelation("invalid", childResource); - + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + assertThrows(InvalidAttributeException.class, () -> funResource.addRelation("invalid", childResource)); } @Test() @@ -845,23 +1377,21 @@ public void testRemoveToManyRelationSuccess() { Parent parent3 = newParent(3, child); child.setParents(Sets.newHashSet(parent1, parent2, parent3)); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); - PersistentResource removeResource = new PersistentResource<>(parent1, null, "1", goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + PersistentResource removeResource = new PersistentResource<>(parent1, "1", goodScope); childResource.removeRelation("parents", removeResource); - Assert.assertEquals(child.getParents().size(), 2, "The many-2-many relationship should be cleared"); - Assert.assertEquals(parent1.getChildren().size(), 0, "The many-2-many inverse relationship should be cleared"); - Assert.assertEquals(parent3.getChildren().size(), 1, "The many-2-many inverse relationship should not be cleared"); - Assert.assertEquals(parent3.getChildren().size(), 1, "The many-2-many inverse relationship should not be cleared"); + assertEquals(2, child.getParents().size(), "The many-2-many relationship should be cleared"); + assertEquals(0, parent1.getChildren().size(), "The many-2-many inverse relationship should be cleared"); + assertEquals(1, parent3.getChildren().size(), "The many-2-many inverse relationship should not be cleared"); + assertEquals(1, parent3.getChildren().size(), "The many-2-many inverse relationship should not be cleared"); - goodScope.saveObjects(); - verify(tx, times(1)).save(child); - verify(tx, times(1)).save(parent1); - verify(tx, never()).save(parent2); - verify(tx, never()).save(parent3); + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(child, goodScope); + verify(tx, times(1)).save(parent1, goodScope); + verify(tx, never()).save(parent2, goodScope); + verify(tx, never()).save(parent3, goodScope); } @Test() @@ -870,20 +1400,18 @@ public void testRemoveToOneRelationSuccess() { Child child = newChild(1); fun.setRelation3(child); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + RequestScope goodScope = buildRequestScope(tx, goodUser); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodScope); - PersistentResource removeResource = new PersistentResource<>(child, null, "1", goodScope); + PersistentResource funResource = new PersistentResource<>(fun, "1", goodScope); + PersistentResource removeResource = new PersistentResource<>(child, "1", goodScope); funResource.removeRelation("relation3", removeResource); - Assert.assertEquals(fun.getRelation3(), null, "The one-2-one relationship should be cleared"); + assertNull(fun.getRelation3(), "The one-2-one relationship should be cleared"); - goodScope.saveObjects(); - verify(tx, times(1)).save(fun); - verify(tx, never()).save(child); + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(fun, goodScope); + verify(tx, never()).save(child, goodScope); } // Test to ensure that save() is not called on unmodified objects @@ -895,21 +1423,57 @@ public void testNoSaveNonModifications() { Parent parent = new Parent(); fun.setRelation3(child); - fun.setRelation1(Sets.newHashSet(child)); - parent.setChildren(Sets.newHashSet(child)); + Set children1 = Sets.newHashSet(child); + fun.setRelation1(children1); + + Set children2 = Sets.newHashSet(child); + parent.setChildren(children2); parent.setFirstName("Leeroy"); child.setReadNoAccess(secret); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - - 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.getToOneRelation(any(), eq(fun), eq(com.yahoo.elide.core.request.Relationship.builder() + .name("relation3") + .alias("relation3") + .projection(EntityProjection.builder() + .type(Child.class) + .build()) + .build()), any())).thenReturn(child); + + when(tx.getToManyRelation(any(), eq(fun), eq(com.yahoo.elide.core.request.Relationship.builder() + .name("relation1") + .alias("relation1") + .projection(EntityProjection.builder() + .type(Child.class) + .build()) + .build()), any())).thenReturn(new DataStoreIterableBuilder(children1).build()); + + when(tx.getToManyRelation(any(), eq(parent), eq(com.yahoo.elide.core.request.Relationship.builder() + .name("children") + .alias("children") + .projection(EntityProjection.builder() + .type(Child.class) + .build()) + .build()), any())).thenReturn(new DataStoreIterableBuilder(children2).build()); + + when(tx.getToOneRelation(any(), eq(child), eq(com.yahoo.elide.core.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, "1", funScope); + PersistentResource childResource = new PersistentResource<>(child, "1", childScope); + PersistentResource secretResource = new PersistentResource<>(secret, "1", childScope); + PersistentResource parentResource = new PersistentResource<>(parent, "1", parentScope); // Add an existing to-one relationship funResource.addRelation("relation3", childResource); @@ -941,11 +1505,13 @@ public void testNoSaveNonModifications() { // Clear empty to-one relation secretResource.clearRelation("readNoAccess"); - goodScope.saveObjects(); - verify(tx, never()).save(fun); - verify(tx, never()).save(child); - verify(tx, never()).save(parent); - verify(tx, never()).save(secret); + 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() @@ -955,19 +1521,17 @@ public void testRemoveNonexistingToOneRelation() { Child unownedChild = newChild(2); fun.setRelation3(ownedChild); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + RequestScope goodScope = buildRequestScope(tx, goodUser); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodScope); - PersistentResource removeResource = new PersistentResource<>(unownedChild, null, "1", goodScope); + PersistentResource funResource = new PersistentResource<>(fun, "1", goodScope); + PersistentResource removeResource = new PersistentResource<>(unownedChild, "1", goodScope); funResource.removeRelation("relation3", removeResource); - Assert.assertEquals(fun.getRelation3(), ownedChild, "The one-2-one relationship should NOT be cleared"); + assertEquals(ownedChild, fun.getRelation3(), "The one-2-one relationship should NOT be cleared"); - verify(tx, never()).save(fun); - verify(tx, never()).save(ownedChild); + verify(tx, never()).save(fun, goodScope); + verify(tx, never()).save(ownedChild, goodScope); } @Test() @@ -980,22 +1544,20 @@ public void testRemoveNonexistingToManyRelation() { Parent unownedParent = newParent(4, null); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource childResource = new PersistentResource<>(child, null, "1", goodScope); - PersistentResource removeResource = new PersistentResource<>(unownedParent, null, "1", goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + PersistentResource removeResource = new PersistentResource<>(unownedParent, "1", goodScope); childResource.removeRelation("parents", removeResource); - Assert.assertEquals(child.getParents().size(), 3, "The many-2-many relationship should not be cleared"); - Assert.assertEquals(parent1.getChildren().size(), 1, "The many-2-many inverse relationship should not be cleared"); - Assert.assertEquals(parent3.getChildren().size(), 1, "The many-2-many inverse relationship should not be cleared"); - Assert.assertEquals(parent3.getChildren().size(), 1, "The many-2-many inverse relationship should not be cleared"); + assertEquals(3, child.getParents().size(), "The many-2-many relationship should not be cleared"); + assertEquals(1, parent1.getChildren().size(), "The many-2-many inverse relationship should not be cleared"); + assertEquals(1, parent3.getChildren().size(), "The many-2-many inverse relationship should not be cleared"); + assertEquals(1, parent3.getChildren().size(), "The many-2-many inverse relationship should not be cleared"); - verify(tx, never()).save(child); - verify(tx, never()).save(parent1); - verify(tx, never()).save(parent2); - verify(tx, never()).save(parent3); + verify(tx, never()).save(child, goodScope); + verify(tx, never()).save(parent1, goodScope); + verify(tx, never()).save(parent2, goodScope); + verify(tx, never()).save(parent3, goodScope); } @Test() @@ -1004,26 +1566,35 @@ public void testClearToManyRelationSuccess() { Parent parent1 = newParent(1, child); Parent parent2 = newParent(2, child); Parent parent3 = newParent(3, child); - child.setParents(Sets.newHashSet(parent1, parent2, parent3)); + Set parents = Sets.newHashSet(parent1, parent2, parent3); + child.setParents(parents); + + when(tx.getToManyRelation(any(), eq(child), any(), any())) + .thenReturn(new DataStoreIterableBuilder(parents).build()); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + 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); + PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); childResource.clearRelation("parents"); - Assert.assertEquals(child.getParents().size(), 0, "The many-2-many relationship should be cleared"); - Assert.assertEquals(parent1.getChildren().size(), 0, "The many-2-many inverse relationship should be cleared"); - Assert.assertEquals(parent3.getChildren().size(), 0, "The many-2-many inverse relationship should be cleared"); - Assert.assertEquals(parent3.getChildren().size(), 0, "The many-2-many inverse relationship should be cleared"); + assertEquals(0, child.getParents().size(), "The many-2-many relationship should be cleared"); + assertEquals(0, parent1.getChildren().size(), "The many-2-many inverse relationship should be cleared"); + assertEquals(0, parent3.getChildren().size(), "The many-2-many inverse relationship should be cleared"); + assertEquals(0, parent3.getChildren().size(), "The many-2-many inverse relationship should be cleared"); - goodScope.saveObjects(); - verify(tx, times(1)).save(child); - verify(tx, times(1)).save(parent1); - verify(tx, times(1)).save(parent2); - verify(tx, times(1)).save(parent3); + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(child, goodScope); + verify(tx, times(1)).save(parent1, goodScope); + verify(tx, times(1)).save(parent2, goodScope); + verify(tx, times(1)).save(parent3, goodScope); } @Test() @@ -1032,25 +1603,39 @@ public void testClearToOneRelationSuccess() { Child child = newChild(1); fun.setRelation3(child); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodScope); + when(tx.getToOneRelation(any(), eq(fun), any(), any())).thenReturn(child); + + 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, "1", goodScope); funResource.clearRelation("relation3"); - Assert.assertEquals(fun.getRelation3(), null, "The one-2-one relationship should be cleared"); + assertNull(fun.getRelation3(), "The one-2-one relationship should be cleared"); - goodScope.saveObjects(); - verify(tx, times(1)).save(fun); - verify(tx, times(1)).save(child); + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(fun, goodScope); + verify(tx, times(1)).save(child, goodScope); } @Test() public void testClearRelationFilteredByReadAccess() { - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); Parent parent = new Parent(); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + 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); @@ -1060,7 +1645,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<>(); @@ -1072,7 +1656,10 @@ public void testClearRelationFilteredByReadAccess() { parent.setChildren(allChildren); parent.setSpouses(Sets.newHashSet()); - PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); + when(tx.getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(allChildren).build()); + + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); //Final set after operation = (4,5) Set expected = new HashSet<>(); @@ -1081,17 +1668,17 @@ public void testClearRelationFilteredByReadAccess() { boolean updated = parentResource.clearRelation("children"); - goodScope.saveObjects(); - verify(tx, times(1)).save(parent); - verify(tx, times(1)).save(child1); - verify(tx, times(1)).save(child2); - verify(tx, times(1)).save(child3); - verify(tx, never()).save(child4); - verify(tx, never()).save(child5); + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(parent, goodScope); + verify(tx, times(1)).save(child1, goodScope); + verify(tx, times(1)).save(child2, goodScope); + verify(tx, times(1)).save(child3, goodScope); + verify(tx, never()).save(child4, goodScope); + verify(tx, never()).save(child5, goodScope); - Assert.assertEquals(updated, true, "The relationship should have been partially cleared."); - Assert.assertTrue(parent.getChildren().containsAll(expected), "The unfiltered remaining members are left"); - Assert.assertTrue(expected.containsAll(parent.getChildren()), "The unfiltered remaining members are left"); + assertTrue(updated, "The relationship should have been partially cleared."); + assertTrue(parent.getChildren().containsAll(expected), "The unfiltered remaining members are left"); + assertTrue(expected.containsAll(parent.getChildren()), "The unfiltered remaining members are left"); /* * No tests for reference integrity since the parent is the owner and @@ -1099,43 +1686,53 @@ public void testClearRelationFilteredByReadAccess() { */ } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testClearRelationInvalidToOneUpdatePermission() { Left left = new Left(); left.setId(1); Right right = new Right(); right.setId(1); - left.noUpdateOne2One = right; - right.noUpdateOne2One = left; - - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource leftResource = new PersistentResource<>(left, null, "1", goodScope); - leftResource.clearRelation("noUpdateOne2One"); + left.setNoUpdateOne2One(right); + right.setNoUpdateOne2One(left); + + 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, "1", goodScope); + + assertThrows( + ForbiddenAccessException.class, + () -> leftResource.clearRelation("noUpdateOne2One")); // Modifications have a deferred check component: leftResource.getRequestScope().getPermissionExecutor().executeCommitChecks(); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testNoChangeRelationInvalidToOneUpdatePermission() { Left left = new Left(); left.setId(1); Right right = new Right(); right.setId(1); - left.noUpdateOne2One = right; - right.noUpdateOne2One = left; - - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource leftResource = new PersistentResource<>(left, null, "1", goodScope); - leftResource.updateRelation("noUpdateOne2One", leftResource.getRelationCheckedFiltered("noUpdateOne2One")); + left.setNoUpdateOne2One(right); + right.setNoUpdateOne2One(left); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource leftResource = new PersistentResource<>(left, "1", goodScope); + + assertThrows( + ForbiddenAccessException.class, + () -> leftResource.updateRelation("noUpdateOne2One", getRelation(leftResource, "noUpdateOne2One"))); // Modifications have a deferred check component: leftResource.getRequestScope().getPermissionExecutor().executeCommitChecks(); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testClearRelationInvalidToManyUpdatePermission() { Left left = new Left(); left.setId(1); @@ -1143,16 +1740,29 @@ public void testClearRelationInvalidToManyUpdatePermission() { right1.setId(1); Right right2 = new Right(); right2.setId(2); - left.noInverseUpdate = Sets.newHashSet(right1, right2); - right1.noUpdate = Sets.newHashSet(left); - right2.noUpdate = Sets.newHashSet(left); + Set noInverseUpdate = Sets.newHashSet(right1, right2); + left.setNoInverseUpdate(noInverseUpdate); + right1.setNoUpdate(Sets.newHashSet(left)); + right2.setNoUpdate(Sets.newHashSet(left)); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource leftResource = new PersistentResource<>(left, null, "1", goodScope); - leftResource.clearRelation("noInverseUpdate"); + when(tx.getToManyRelation(any(), eq(left), any(), any())) + .thenReturn(new DataStoreIterableBuilder(noInverseUpdate).build()); + + 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, "1", goodScope); + + assertThrows( + ForbiddenAccessException.class, + () -> leftResource.clearRelation("noInverseUpdate")); // Modifications have a deferred check component: leftResource.getRequestScope().getPermissionExecutor().executeCommitChecks(); } @@ -1163,87 +1773,338 @@ public void testClearRelationInvalidToOneDeletePermission() { left.setId(1); NoDeleteEntity noDelete = new NoDeleteEntity(); noDelete.setId(1); - left.noDeleteOne2One = noDelete; + left.setNoDeleteOne2One(noDelete); + + when(tx.getToOneRelation(any(), eq(left), any(), any())).thenReturn(noDelete); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource leftResource = new PersistentResource<>(left, null, "1", goodScope); - Assert.assertTrue(leftResource.clearRelation("noDeleteOne2One")); - Assert.assertNull(leftResource.getObject().noDeleteOne2One); + 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, "1", goodScope); + assertTrue(leftResource.clearRelation("noDeleteOne2One")); + assertNull(leftResource.getObject().getNoDeleteOne2One()); } - @Test(expectedExceptions = InvalidAttributeException.class) + @Test public void testClearRelationInvalidRelation() { FunWithPermissions fun = new FunWithPermissions(); Child child = newChild(1); fun.setRelation3(child); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", goodScope); - funResource.clearRelation("invalid"); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource funResource = new PersistentResource<>(fun, "1", goodScope); + assertThrows(InvalidAttributeException.class, () -> funResource.clearRelation("invalid")); } @Test public void testUpdateAttributeSuccess() { Parent parent = newParent(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); parentResource.updateAttribute("firstName", "foobar"); - Assert.assertEquals(parent.getFirstName(), "foobar", "The attribute was updated successfully"); - goodScope.saveObjects(); - verify(tx, times(1)).save(parent); + assertEquals("foobar", parent.getFirstName(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(parent, goodScope); + } + + @Test + public void testUpdateComplexAttributeSuccess() { + Company company = newCompany("abc"); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + final Address address = new Address(); + address.setStreet1("street1"); + address.setStreet2("street2"); + parentResource.updateAttribute("address", address); + + assertEquals(address, company.getAddress(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + + @Test + public void testUpdateComplexAttributeCloneWithHook() { + reset(bookUpdatePrice); + Book book = new Book(); + + Price originalPrice = new Price(); + originalPrice.setUnits(new BigDecimal(1.0)); + originalPrice.setCurrency(Currency.getInstance("USD")); + book.setPrice(originalPrice); + + Map newPrice = new HashMap<>(); + newPrice.put("units", new BigDecimal(2.0)); + newPrice.put("currency", Currency.getInstance("CNY")); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource bookResource = new PersistentResource<>(book, "1", goodScope); + bookResource.updateAttribute("price", newPrice); + + //check that original value was unmodified. + assertEquals(Currency.getInstance("USD"), originalPrice.getCurrency()); + assertEquals(new BigDecimal(1.0), originalPrice.getUnits()); + + //check that new value matches expected. + assertEquals(Currency.getInstance("CNY"), book.getPrice().getCurrency()); + assertEquals(new BigDecimal(2.0), book.getPrice().getUnits()); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(book, goodScope); + + ArgumentCaptor eventCapture = ArgumentCaptor.forClass(CRUDEvent.class); + verify(bookUpdatePrice, times(1)).execute(eq(UPDATE), eq(PRESECURITY), + eventCapture.capture()); + + assertEquals(originalPrice, eventCapture.getValue().getChanges().get().getOriginal()); + assertEquals(book.getPrice(), eventCapture.getValue().getChanges().get().getModified()); + } + + @Test + public void testUpdateNestedComplexAttributeClone() { + Company company = newCompany("abc"); + Address originalAddress = new Address(); + originalAddress.setStreet1("street1"); + originalAddress.setStreet2("street2"); + + GeoLocation originalGeo = new GeoLocation(); + originalGeo.setLatitude("1"); + originalGeo.setLongitude("2"); + originalAddress.setGeo(originalGeo); + + Map newAddress = new HashMap<>(); + newAddress.put("street1", "Elm"); + newAddress.put("street2", "Maple"); + Map newGeo = new HashMap<>(); + newGeo.put("latitude", "X"); + newGeo.put("longitude", "Y"); + newAddress.put("geo", newGeo); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + + parentResource.updateAttribute("address", newAddress); + + //check that original value was unmodified. + assertEquals("street1", originalAddress.getStreet1()); + assertEquals("street2", originalAddress.getStreet2()); + assertEquals("1", originalAddress.getGeo().getLatitude()); + assertEquals("2", originalAddress.getGeo().getLongitude()); + + //check the new value matches the expected. + assertEquals("Elm", company.getAddress().getStreet1()); + assertEquals("Maple", company.getAddress().getStreet2()); + assertEquals("X", company.getAddress().getGeo().getLatitude()); + assertEquals("Y", company.getAddress().getGeo().getLongitude()); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + + @Test + public void testUpdateNullComplexAttributeSuccess() { + Company company = newCompany("abc"); + company.setAddress(new Address()); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + final Address address = null; + + boolean updated = parentResource.updateAttribute("address", address); + + assertTrue(updated); + assertNull(company.getAddress(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + + @Test + public void testUpdateComplexAttributeNullField() { + Company company = newCompany("abc"); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + final Address address = new Address(); + address.setStreet1("street1"); + address.setStreet2(null); + parentResource.updateAttribute("address", address); + + assertEquals(address, company.getAddress(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + + @Test + public void testUpdateComplexAttributeAllNullFields() { + Company company = newCompany("abc"); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + final Address address = new Address(); + address.setStreet1(null); + address.setStreet2(null); + parentResource.updateAttribute("address", address); + + assertEquals(address, company.getAddress(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + + @Test + public void testUpdateComplexAttributeNested() { + Company company = newCompany("abc"); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + final Address address = new Address(); + address.setStreet1("street1"); + address.setStreet2("street2"); + final GeoLocation geo = new GeoLocation(); + geo.setLatitude("lat"); + geo.setLongitude("long"); + address.setGeo(geo); + parentResource.updateAttribute("address", address); + + assertEquals(address, company.getAddress(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + + @Test + public void testUpdateComplexAttributeNestedNullField() { + Company company = newCompany("abc"); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + final Address address = new Address(); + address.setStreet1("street1"); + address.setStreet2("street2"); + final GeoLocation geo = new GeoLocation(); + geo.setLatitude(null); + geo.setLongitude("long"); + address.setGeo(geo); + parentResource.updateAttribute("address", address); + + assertEquals(address, company.getAddress(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + + @Test + public void testUpdateComplexAttributeNestedAllNullFields() { + Company company = newCompany("abc"); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + final Address address = new Address(); + address.setStreet1("street1"); + address.setStreet2("street2"); + final GeoLocation geo = new GeoLocation(); + geo.setLatitude(null); + geo.setLongitude(null); + address.setGeo(geo); + parentResource.updateAttribute("address", address); + + assertEquals(address, company.getAddress(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + + @Test + public void testUpdateComplexAttributeAllNullFieldsNestedAllNullFields() { + Company company = newCompany("abc"); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + final Address address = new Address(); + address.setStreet1(null); + address.setStreet2(null); + final GeoLocation geo = new GeoLocation(); + geo.setLatitude(null); + geo.setLongitude(null); + address.setGeo(geo); + parentResource.updateAttribute("address", address); + + assertEquals(address, company.getAddress(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + + @Test + public void testUpdateComplexAttributeAllNullFieldsNested() { + Company company = newCompany("abc"); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + final Address address = new Address(); + address.setStreet1(null); + address.setStreet2(null); + final GeoLocation geo = new GeoLocation(); + geo.setLatitude("lat"); + geo.setLongitude("long"); + address.setGeo(geo); + parentResource.updateAttribute("address", address); + + assertEquals(address, company.getAddress(), "The attribute was updated successfully"); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); } - @Test(expectedExceptions = InvalidAttributeException.class) + @Test public void testUpdateAttributeInvalidAttribute() { Parent parent = newParent(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource parentResource = new PersistentResource<>(parent, null, "1", goodScope); - parentResource.updateAttribute("invalid", "foobar"); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); + assertThrows(InvalidAttributeException.class, () -> parentResource.updateAttribute("invalid", "foobar")); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testUpdateAttributeInvalidUpdatePermission() { FunWithPermissions fun = new FunWithPermissions(); fun.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User badUser = new User(-1); + RequestScope badScope = buildRequestScope(tx, badUser); - RequestScope badScope = new RequestScope(null, null, tx, badUser, dictionary, null, MOCK_AUDIT_LOGGER); + PersistentResource funResource = new PersistentResource<>(fun, "1", badScope); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", badScope); - funResource.updateAttribute("field4", "foobar"); + assertThrows( + ForbiddenAccessException.class, + () -> funResource.updateAttribute("field4", "foobar")); // Updates will defer and wait for the end! funResource.getRequestScope().getPermissionExecutor().executeCommitChecks(); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testUpdateAttributeInvalidUpdatePermissionNoChange() { FunWithPermissions fun = new FunWithPermissions(); fun.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User badUser = new User(-1); - RequestScope badScope = new RequestScope(null, null, tx, badUser, dictionary, null, MOCK_AUDIT_LOGGER); + RequestScope badScope = buildRequestScope(tx, badUser); + PersistentResource funResource = new PersistentResource<>(fun, "1", badScope); - PersistentResource funResource = new PersistentResource<>(fun, null, "1", badScope); - funResource.updateAttribute("field4", funResource.getAttribute("field4")); + assertThrows( + ForbiddenAccessException.class, + () -> funResource.updateAttribute("field4", funResource.getAttribute("field4"))); // Updates will defer and wait for the end! funResource.getRequestScope().getPermissionExecutor().executeCommitChecks(); } @@ -1256,23 +2117,30 @@ public void testLoadRecords() { Child child4 = newChild(4); Child child5 = newChild(5); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); + EntityProjection collection = EntityProjection.builder() + .type(Child.class) + + .build(); - when(tx.loadObjects(eq(Child.class), any(FilterScope.class))) - .thenReturn(Lists.newArrayList(child1, child2, child3, child4, child5)); + when(tx.loadObjects(eq(collection), any(RequestScope.class))) + .thenReturn(new DataStoreIterableBuilder( + Lists.newArrayList(child1, child2, child3, child4, child5)).build()); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - Set> loaded = PersistentResource.loadRecords(Child.class, goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(collection); + + Set loaded = PersistentResource.loadRecords(EntityProjection.builder() + .type(Child.class) + .build(), new ArrayList<>(), goodScope).toList(LinkedHashSet::new).blockingGet(); Set expected = Sets.newHashSet(child1, child4, child5); Set actual = loaded.stream().map(PersistentResource::getObject).collect(Collectors.toSet()); - Assert.assertEquals(actual.size(), 3, + assertEquals(3, actual.size(), "The returned list should be filtered to only include elements that have read permission" ); - Assert.assertEquals(expected, actual, + assertEquals(expected, actual, "The returned list should only include elements with a positive ID" ); } @@ -1281,71 +2149,122 @@ public void testLoadRecords() { public void testLoadRecordSuccess() { Child child1 = newChild(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); + EntityProjection collection = EntityProjection.builder() + .type(Child.class) + + .build(); - when(tx.loadObject(Child.class, 1L)).thenReturn(child1); + when(tx.loadObject(eq(collection), eq(1L), any())).thenReturn(child1); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource loaded = PersistentResource.loadRecord(Child.class, "1", goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(collection); + PersistentResource loaded = PersistentResource.loadRecord(EntityProjection.builder() + .type(Child.class) + .build(), "1", goodScope); - Assert.assertEquals(loaded.getObject(), child1, "The load function should return the requested child object"); + assertEquals(child1, loaded.getObject(), "The load function should return the requested child object"); } - @Test(expectedExceptions = InvalidObjectIdentifierException.class) + @Test public void testLoadRecordInvalidId() { - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); + EntityProjection collection = EntityProjection.builder() + .type(Child.class) + + .build(); - when(tx.loadObject(Child.class, "1")).thenReturn(null); + when(tx.loadObject(eq(collection), eq("1"), any())).thenReturn(null); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource.loadRecord(Child.class, "1", goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(collection); + assertThrows( + InvalidObjectIdentifierException.class, + () -> PersistentResource.loadRecord(EntityProjection.builder() + + .type(Child.class) + .build(), "1", goodScope)); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testLoadRecordForbidden() { NoReadEntity noRead = new NoReadEntity(); noRead.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); + EntityProjection collection = EntityProjection.builder() + .type(NoReadEntity.class) + + .build(); + + when(tx.loadObject(eq(collection), eq(1L), any())).thenReturn(noRead); - when(tx.loadObject(NoReadEntity.class, 1L)).thenReturn(noRead); + RequestScope goodScope = buildRequestScope(tx, goodUser); + goodScope.setEntityProjection(collection); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource.loadRecord(NoReadEntity.class, "1", goodScope); + assertThrows( + ForbiddenAccessException.class, + () -> PersistentResource.loadRecord(EntityProjection.builder().type(NoReadEntity.class).build(), + "1", goodScope)); } @Test() public void testCreateObjectSuccess() { Parent parent = newParent(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); + RequestScope goodScope = buildRequestScope(tx, goodUser); + when(tx.createNewObject(ClassType.of(Parent.class), goodScope)).thenReturn(parent); - when(tx.createObject(Parent.class)).thenReturn(parent); + PersistentResource created = PersistentResource.createObject(ClassType.of(Parent.class), goodScope, Optional.of("uuid")); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource created = PersistentResource.createObject(Parent.class, goodScope, "uuid"); parent.setChildren(new HashSet<>()); created.getRequestScope().getPermissionExecutor().executeCommitChecks(); - Assert.assertEquals(created.getObject(), parent, + assertEquals(parent, created.getObject(), "The create function should return the requested parent object" ); + assertTrue(goodScope.isNewResource(parent)); + } + + @Test() + public void testCreateMappedIdObjectSuccess() { + final Job job = new Job(); + job.setTitle("day job"); + job.setParent(newParent(1)); + + final RequestScope goodScope = buildRequestScope(tx, new TestUser("1")); + when(tx.createNewObject(ClassType.of(Job.class), goodScope)).thenReturn(job); + + PersistentResource created = PersistentResource.createObject(ClassType.of(Job.class), goodScope, Optional.empty()); + + created.getRequestScope().getPermissionExecutor().executeCommitChecks(); + + assertEquals("day job", created.getObject().getTitle(), + "The create function should return the requested job object" + ); + assertNull(created.getObject().getJobId(), "The create function should not override the ID"); + + created = PersistentResource.createObject(ClassType.of(Job.class), goodScope, Optional.of("1234")); + created.getRequestScope().getPermissionExecutor().executeCommitChecks(); + + assertEquals("day job", created.getObject().getTitle(), + "The create function should return the requested job object" + ); + assertNull(created.getObject().getJobId(), "The create function should not override the ID"); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testCreateObjectForbidden() { NoCreateEntity noCreate = new NoCreateEntity(); noCreate.setId(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - User goodUser = new User(1); - when(tx.createObject(NoCreateEntity.class)).thenReturn(noCreate); + RequestScope goodScope = buildRequestScope(tx, goodUser); + when(tx.createNewObject(ClassType.of(NoCreateEntity.class), goodScope)).thenReturn(noCreate); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource created = PersistentResource.createObject(NoCreateEntity.class, goodScope, "uuid"); - created.getRequestScope().getPermissionExecutor().executeCommitChecks(); + assertThrows( + ForbiddenAccessException.class, + () -> { + PersistentResource created = PersistentResource.createObject( + ClassType.of(NoCreateEntity.class), + goodScope, Optional.of("1")); + created.getRequestScope().getPermissionExecutor().executeCommitChecks(); + } + ); } @Test @@ -1355,38 +2274,43 @@ public void testDeletePermissionCheckedOnInverseRelationship() { Right right = new Right(); right.setId(2); - left.fieldLevelDelete = Sets.newHashSet(right); - right.allowDeleteAtFieldLevel = Sets.newHashSet(left); + Set rights = Sets.newHashSet(right); + left.setFieldLevelDelete(Sets.newHashSet(right)); + right.setAllowDeleteAtFieldLevel(Sets.newHashSet(left)); //Bad User triggers the delete permission failure - User badUser = new User(-1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - RequestScope badScope = new RequestScope(null, null, tx, badUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource leftResource = new PersistentResource<>(left, null, badScope); + when(tx.getToManyRelation(any(), eq(left), any(), any())).thenReturn(new DataStoreIterableBuilder(rights).build()); + + RequestScope badScope = buildRequestScope(tx, badUser); + PersistentResource leftResource = new PersistentResource<>(left, badScope.getUUIDFor(left), badScope); - Assert.assertTrue(leftResource.clearRelation("fieldLevelDelete")); - Assert.assertEquals(leftResource.getObject().fieldLevelDelete.size(), 0); + assertTrue(leftResource.clearRelation("fieldLevelDelete")); + assertEquals(0, leftResource.getObject().getFieldLevelDelete().size()); } - @Test(expectedExceptions = ForbiddenAccessException.class) + @Test public void testUpdatePermissionCheckedOnInverseRelationship() { Left left = new Left(); left.setId(1); Right right = new Right(); - left.noInverseUpdate = Sets.newHashSet(right); - right.noUpdate = Sets.newHashSet(left); + Set rights = Sets.newHashSet(right); + left.setNoInverseUpdate(rights); + right.setNoUpdate(Sets.newHashSet(left)); List empty = new ArrayList<>(); Relationship ids = new Relationship(null, new Data<>(empty)); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource leftResource = new PersistentResource<>(left, null, goodScope); + when(tx.getToManyRelation(any(), eq(left), any(), any())) + .thenReturn(new DataStoreIterableBuilder(rights).build()); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource leftResource = new PersistentResource<>(left, goodScope.getUUIDFor(left), goodScope); - leftResource.updateRelation("noInverseUpdate", ids.toPersistentResources(goodScope)); + assertThrows( + ForbiddenAccessException.class, + () -> leftResource.updateRelation("noInverseUpdate", ids.toPersistentResources(goodScope))); // Modifications have a deferred check component: leftResource.getRequestScope().getPermissionExecutor().executeCommitChecks(); } @@ -1397,49 +2321,51 @@ public void testFieldLevelAudit() throws Exception { Parent parent = newParent(7); - User goodUser = new User(1); TestAuditLogger logger = new TestAuditLogger(); - PersistentResource parentResource = new PersistentResource<>( - parent, getUserScope(goodUser, logger)); + RequestScope requestScope = getUserScope(goodUser, logger); + PersistentResource parentResource = + new PersistentResource<>(parent, requestScope.getUUIDFor(parent), requestScope); PersistentResource childResource = new PersistentResource<>( - parentResource, child, getUserScope(goodUser, logger)); + child, + parentResource, + "children", + requestScope.getUUIDFor(child), + requestScope); childResource.auditField(new ChangeSpec(childResource, "name", parent, null)); - Assert.assertEquals(logger.getMessages().size(), 1, "One message should be logged"); + assertEquals(1, logger.getMessages().size(), "One message should be logged"); LogMessage message = logger.getMessages().get(0); - Assert.assertEquals(message.getMessage(), "UPDATE Child 5 Parent 7", "Logging template should match"); + assertEquals("UPDATE Child 5 Parent 7", message.getMessage(), "Logging template should match"); - Assert.assertEquals(message.getOperationCode(), 1, "Operation code should match"); + assertEquals(1, message.getOperationCode(), "Operation code should match"); + logger.clear(); // tidy up this thread's messages } @Test 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<>( - parent, - getUserScope(goodUser, logger) - ); + parent, requestScope.getUUIDFor(parent), requestScope); PersistentResource childResource = new PersistentResource<>( - parentResource, - child, - getUserScope(goodUser, logger) - ); + child, parentResource, "children", requestScope.getUUIDFor(child), requestScope); - childResource.auditClass(Audit.Action.CREATE, new ChangeSpec(childResource, null, null, childResource.getObject())); + childResource.auditClass( + Audit.Action.CREATE, + new ChangeSpec(childResource, null, null, childResource.getObject())); - Assert.assertEquals(logger.getMessages().size(), 1, "One message should be logged"); + assertEquals(1, logger.getMessages().size(), "One message should be logged"); LogMessage message = logger.getMessages().get(0); - Assert.assertEquals(message.getMessage(), "CREATE Child 5 Parent 7", "Logging template should match"); + assertEquals("CREATE Child 5 Parent 7", message.getMessage(), "Logging template should match"); - Assert.assertEquals(message.getOperationCode(), 0, "Operation code should match"); + assertEquals(0, message.getOperationCode(), "Operation code should match"); + logger.clear(); // tidy up this thread's messages } @Test @@ -1447,53 +2373,65 @@ public void testOwningRelationshipInverseUpdates() { Parent parent = newParent(1); Child child = newChild(2); - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); + when(tx.getToManyRelation(any(), eq(parent), any(), any())).thenReturn(new DataStoreIterableBuilder(parent.getChildren()).build()); - PersistentResource parentResource = new PersistentResource<>(parent, goodScope); - PersistentResource childResource = new PersistentResource<>(parentResource, child, goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + + PersistentResource parentResource = + new PersistentResource<>(parent, goodScope.getUUIDFor(parent), goodScope); + PersistentResource childResource = new PersistentResource<>( + child, + parentResource, + "children", + goodScope.getUUIDFor(child), + goodScope); parentResource.addRelation("children", childResource); - goodScope.saveObjects(); + goodScope.saveOrCreateObjects(); goodScope.getDirtyResources().clear(); - verify(tx, times(1)).save(parent); - verify(tx, times(1)).save(child); + verify(tx, times(1)).save(parent, goodScope); + verify(tx, times(1)).save(child, goodScope); - Assert.assertEquals(parent.getChildren().size(), 1, "The owning relationship should be updated"); - Assert.assertTrue(parent.getChildren().contains(child), "The owning relationship should be updated"); + assertEquals(1, parent.getChildren().size(), "The owning relationship should be updated"); + assertTrue(parent.getChildren().contains(child), "The owning relationship should be updated"); - Assert.assertEquals(child.getParents().size(), 1, "The non-owning relationship should also be updated"); - Assert.assertTrue(child.getParents().contains(parent), "The non-owning relationship should also be updated"); + assertEquals(1, child.getParents().size(), "The non-owning relationship should also be updated"); + assertTrue(child.getParents().contains(parent), "The non-owning relationship should also be updated"); reset(tx); + when(tx.getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(parent.getChildren()).build()); + parentResource.clearRelation("children"); - goodScope.saveObjects(); - verify(tx, times(1)).save(parent); - verify(tx, times(1)).save(child); + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(parent, goodScope); + verify(tx, times(1)).save(child, goodScope); - Assert.assertEquals(parent.getChildren().size(), 0, "The owning relationship should be updated"); - Assert.assertEquals(child.getParents().size(), 0, "The non-owning relationship should also be updated"); + assertEquals(0, parent.getChildren().size(), "The owning relationship should be updated"); + assertEquals(0, child.getParents().size(), "The non-owning relationship should also be updated"); } @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(), "1", scope); - Assert.assertTrue(generated.isIdGenerated(), + 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(), "1", scope); - Assert.assertFalse(notGenerated.isIdGenerated(), + assertFalse(notGenerated.isIdGenerated(), "isIdGenerated returns false when ID field does not have the GeneratedValue annotation"); } - @Test(expectedExceptions = ForbiddenAccessException.class) - public void testSharePermissionErrorOnUpdateSingularRelationship() { + @Test + public void testTransferPermissionErrorOnUpdateSingularRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -1504,18 +2442,72 @@ 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, Answers.CALLS_REAL_METHODS); - when(tx.loadObject(NoShareEntity.class, 1L)).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, goodScope.getUUIDFor(userModel), goodScope); + + assertThrows( + ForbiddenAccessException.class, + () -> userResource.updateRelation("noShare", ids.toPersistentResources(goodScope))); + } + + @Test + public void testTransferPermissionErrorOnUpdateRelationshipPackageLevel() { + ContainerWithPackageShare containerWithPackageShare = new ContainerWithPackageShare(); + + Untransferable untransferable = new Untransferable(); + untransferable.setContainerWithPackageShare(containerWithPackageShare); + + List unShareableList = new ArrayList<>(); + unShareableList.add(new ResourceIdentifier("untransferable", "1").castToResource()); + Relationship unShareales = new Relationship(null, new Data<>(unShareableList)); + + when(tx.loadObject(any(), eq(1L), any())).thenReturn(untransferable); + + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource containerResource = new PersistentResource<>( + containerWithPackageShare, goodScope.getUUIDFor(containerWithPackageShare), goodScope); + + assertThrows( + ForbiddenAccessException.class, + () -> containerResource.updateRelation( + "untransferables", unShareales.toPersistentResources(goodScope))); + } + + @Test + public void testTransferPermissionSuccessOnUpdateManyRelationshipPackageLevel() { + ContainerWithPackageShare containerWithPackageShare = new ContainerWithPackageShare(); + + ShareableWithPackageShare shareableWithPackageShare = new ShareableWithPackageShare(); + shareableWithPackageShare.setContainerWithPackageShare(containerWithPackageShare); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope); + List shareableList = new ArrayList<>(); + shareableList.add(new ResourceIdentifier("shareableWithPackageShare", "1").castToResource()); + Relationship shareables = new Relationship(null, new Data<>(shareableList)); - userResource.updateRelation("noShare", ids.toPersistentResources(goodScope)); + when(tx.loadObject(any(), eq(1L), any())).thenReturn(shareableWithPackageShare); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource containerResource = new PersistentResource<>( + containerWithPackageShare, goodScope.getUUIDFor(containerWithPackageShare), goodScope); + + containerResource.updateRelation( + "shareableWithPackageShares", shareables.toPersistentResources(goodScope)); + + assertEquals(1, containerWithPackageShare.getShareableWithPackageShares().size()); + assertTrue(containerWithPackageShare.getShareableWithPackageShares().contains(shareableWithPackageShare)); } - @Test(expectedExceptions = ForbiddenAccessException.class) - public void testSharePermissionErrorOnUpdateManyRelationship() { + @Test + public void testTransferPermissionErrorOnUpdateManyRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -1529,19 +2521,20 @@ 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, Answers.CALLS_REAL_METHODS); - when(tx.loadObject(NoShareEntity.class, 1L)).thenReturn(noShare1); - when(tx.loadObject(NoShareEntity.class, 2L)).thenReturn(noShare2); + when(tx.loadObject(any(), eq(1L), any())).thenReturn(noShare1); + when(tx.loadObject(any(), eq(2L), any())).thenReturn(noShare2); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource userResource = + new PersistentResource<>(userModel, goodScope.getUUIDFor(userModel), goodScope); - userResource.updateRelation("noShares", ids.toPersistentResources(goodScope)); + assertThrows( + ForbiddenAccessException.class, + () -> userResource.updateRelation("noShares", ids.toPersistentResources(goodScope))); } @Test - public void testSharePermissionSuccessOnUpdateManyRelationship() { + public void testTransferPermissionSuccessOnUpdateManyRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -1558,22 +2551,23 @@ 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, Answers.CALLS_REAL_METHODS); - when(tx.loadObject(NoShareEntity.class, 1L)).thenReturn(noShare1); + when(tx.loadObject(any(), eq(1L), any())).thenReturn(noShare1); + when(tx.getToManyRelation(any(), eq(userModel), any(), any())) + .thenReturn(new DataStoreIterableBuilder(noshares).build()); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource userResource = + new PersistentResource<>(userModel, goodScope.getUUIDFor(userModel), goodScope); boolean returnVal = userResource.updateRelation("noShares", ids.toPersistentResources(goodScope)); - Assert.assertTrue(returnVal); - Assert.assertEquals(userModel.getNoShares().size(), 1); - Assert.assertTrue(userModel.getNoShares().contains(noShare1)); + assertTrue(returnVal); + assertEquals(1, userModel.getNoShares().size()); + assertTrue(userModel.getNoShares().contains(noShare1)); } @Test - public void testSharePermissionSuccessOnUpdateSingularRelationship() { + public void testTransferPermissionSuccessOnUpdateSingularRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -1586,21 +2580,21 @@ 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, Answers.CALLS_REAL_METHODS); - when(tx.loadObject(NoShareEntity.class, 1L)).thenReturn(noShare); + when(tx.getToOneRelation(any(), eq(userModel), any(), any())).thenReturn(noShare); + when(tx.loadObject(any(), eq(1L), any())).thenReturn(noShare); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource userResource = + new PersistentResource<>(userModel, goodScope.getUUIDFor(userModel), goodScope); boolean returnVal = userResource.updateRelation("noShare", ids.toPersistentResources(goodScope)); - Assert.assertFalse(returnVal); - Assert.assertEquals(userModel.getNoShare(), noShare); + assertFalse(returnVal); + assertEquals(noShare, userModel.getNoShare()); } @Test - public void testSharePermissionSuccessOnClearSingularRelationship() { + public void testTransferPermissionSuccessOnClearSingularRelationship() { example.User userModel = new example.User(); userModel.setId(1); @@ -1612,247 +2606,349 @@ 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, Answers.CALLS_REAL_METHODS); + when(tx.getToOneRelation(any(), eq(userModel), any(), any())).thenReturn(noShare); - RequestScope goodScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - PersistentResource userResource = new PersistentResource<>(userModel, null, goodScope); + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource userResource = + new PersistentResource<>(userModel, goodScope.getUUIDFor(userModel), goodScope); boolean returnVal = userResource.updateRelation("noShare", ids.toPersistentResources(goodScope)); - Assert.assertTrue(returnVal); - Assert.assertNull(userModel.getNoShare()); + assertTrue(returnVal); + assertNull(userModel.getNoShare()); + } + + @Test + public void testTransferPermissionErrorOnLineageAncestor() { + NoTransferBiDirectional a = new NoTransferBiDirectional(); + a.setId(1); + NoTransferBiDirectional b = new NoTransferBiDirectional(); + b.setId(2); + NoTransferBiDirectional c = new NoTransferBiDirectional(); + c.setId(3); + a.setOther(b); + b.setOther(c); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource aResource = new PersistentResource(a, "1", goodScope); + PersistentResource bResource = new PersistentResource(b, aResource, "other", "2", goodScope); + PersistentResource cResource = new PersistentResource(c, bResource, "other", "3", goodScope); + + assertThrows( + ForbiddenAccessException.class, + () -> cResource.addRelation("other", aResource)); + } + + @Test + public void testTransferPermissionSuccessOnLineageParent() { + NoTransferBiDirectional a = new NoTransferBiDirectional(); + a.setId(1); + NoTransferBiDirectional b = new NoTransferBiDirectional(); + b.setId(2); + NoTransferBiDirectional c = new NoTransferBiDirectional(); + c.setId(3); + a.setOther(b); + b.setOther(c); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource aResource = new PersistentResource(a, "1", goodScope); + PersistentResource bResource = new PersistentResource(b, aResource, "other", "2", goodScope); + PersistentResource cResource = new PersistentResource(c, bResource, "other", "3", goodScope); + + cResource.addRelation("other", bResource); + } + + @Test + public void testNoTransferStrictPermissionFailure() { + StrictNoTransfer a = new StrictNoTransfer(); + a.setId(1); + StrictNoTransfer b = new StrictNoTransfer(); + b.setId(2); + StrictNoTransfer c = new StrictNoTransfer(); + c.setId(3); + a.setOther(b); + b.setOther(c); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource aResource = new PersistentResource(a, "1", goodScope); + PersistentResource bResource = new PersistentResource(b, aResource, "other", "2", goodScope); + PersistentResource cResource = new PersistentResource(c, bResource, "other", "3", goodScope); + + assertThrows( + ForbiddenAccessException.class, + () -> cResource.addRelation("other", bResource)); } @Test public void testCollectionChangeSpecType() { - Function, Boolean>> collectionCheck = - (fieldName) -> - (spec, condFn) -> { - if (!fieldName.equals(spec.getFieldName())) { - throw new IllegalStateException("Wrong field name: '" + spec.getFieldName() + "'. Expected: '" + fieldName + "'"); - } - return condFn.apply((Collection) spec.getOriginal(), (Collection) spec.getModified()); - }; + Function>> collectionCheck = + (fieldName) -> (spec, condFn) -> { + assertEquals(fieldName, spec.getFieldName()); + return condFn.test((Collection) spec.getOriginal(), (Collection) spec.getModified()); + }; + // Ensure that change specs coming from collections work properly - PersistentResource model = bootstrapPersistentResource(new ChangeSpecModel((spec) -> collectionCheck.apply("testColl").apply(spec, (original, modified) -> original == null && modified.equals(Arrays.asList("a", "b", "c"))))); + + ChangeSpecModel csModel = new ChangeSpecModel((spec) -> collectionCheck + .apply("testColl") + .test(spec, (original, modified) -> original == null && modified.equals(Arrays.asList("a", "b", "c")))); + + PersistentResource model = bootstrapPersistentResource(csModel, tx); + + when(tx.getToManyRelation(any(), eq(model.obj), any(), any())) + .thenReturn(new DataStoreIterableBuilder<>().build()); /* Attributes */ // Set new data from null - Assert.assertTrue(model.updateAttribute("testColl", Arrays.asList("a", "b", "c"))); + assertTrue(model.updateAttribute("testColl", Arrays.asList("a", "b", "c"))); // Set data to empty - model.getObject().checkFunction = (spec) -> collectionCheck.apply("testColl").apply(spec, + model.getObject().checkFunction = (spec) -> collectionCheck.apply("testColl").test(spec, (original, modified) -> original.equals(Arrays.asList("a", "b", "c")) && modified.isEmpty()); - Assert.assertTrue(model.updateAttribute("testColl", Lists.newArrayList())); + assertTrue(model.updateAttribute("testColl", Lists.newArrayList())); - model.getObject().checkFunction = (spec) -> collectionCheck.apply("testColl").apply(spec, (original, modified) -> original.isEmpty() && modified.equals(Arrays.asList("final", "List"))); + model.getObject().checkFunction = (spec) -> collectionCheck + .apply("testColl") + .test( + spec, + (original, modified) -> original.isEmpty() && modified.equals(Arrays.asList("final", "List"))); // / Overwrite attribute data - Assert.assertTrue(model.updateAttribute("testColl", Arrays.asList("final", "List"))); + assertTrue(model.updateAttribute("testColl", Arrays.asList("final", "List"))); /* ToMany relationships */ // Learn about the other kids - model.getObject().checkFunction = (spec) -> collectionCheck.apply("otherKids").apply(spec, (original, modified) -> (original == null || original.isEmpty()) && modified.size() == 1 && modified.contains(new ChangeSpecChild(1))); - Assert.assertTrue(model.updateRelation("otherKids", Sets.newHashSet(bootstrapPersistentResource(new ChangeSpecChild(1))))); + model.getObject().checkFunction = (spec) -> collectionCheck + .apply("otherKids") + .test( + spec, + (original, modified) -> + CollectionUtils.isEmpty(original) + && modified.size() == 1 + && modified.contains(new ChangeSpecChild(1))); + + ChangeSpecChild child1 = new ChangeSpecChild(1); + assertTrue(model.updateRelation("otherKids", Sets.newHashSet(bootstrapPersistentResource(child1)))); // Add individual - model.getObject().checkFunction = (spec) -> collectionCheck.apply("otherKids").apply(spec, (original, modified) -> original.equals(Arrays.asList(new ChangeSpecChild(1))) && modified.size() == 2 && modified.contains(new ChangeSpecChild(1)) && modified.contains(new ChangeSpecChild(2))); - model.addRelation("otherKids", bootstrapPersistentResource(new ChangeSpecChild(2))); - model.getObject().checkFunction = (spec) -> collectionCheck.apply("otherKids").apply(spec, (original, modified) -> original.size() == 2 && original.contains(new ChangeSpecChild(1)) && original.contains(new ChangeSpecChild(2)) && modified.size() == 3 && modified.contains(new ChangeSpecChild(1)) && modified.contains(new ChangeSpecChild(2)) && modified.contains(new ChangeSpecChild(3))); - model.addRelation("otherKids", bootstrapPersistentResource(new ChangeSpecChild(3))); + model.getObject().checkFunction = + (spec) -> collectionCheck + .apply("otherKids") + .test( + spec, + (original, modified) -> + original.equals(Collections.singletonList(new ChangeSpecChild(1))) + && modified.size() == 2 + && modified.contains(new ChangeSpecChild(1)) + && modified.contains(new ChangeSpecChild(2))); + + ChangeSpecChild child2 = new ChangeSpecChild(2); + model.addRelation("otherKids", bootstrapPersistentResource(child2)); + + model.getObject().checkFunction = (spec) -> collectionCheck + .apply("otherKids") + .test( + spec, + (original, modified) -> + original.size() == 2 + && original.contains(new ChangeSpecChild(1)) + && original.contains(new ChangeSpecChild(2)) + && modified.size() == 3 + && modified.contains(new ChangeSpecChild(1)) + && modified.contains(new ChangeSpecChild(2)) + && modified.contains(new ChangeSpecChild(3))); + + ChangeSpecChild child3 = new ChangeSpecChild(3); + model.addRelation("otherKids", bootstrapPersistentResource(child3)); // Remove one - 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(new ChangeSpecChild(2))); + model.getObject().checkFunction = (spec) -> collectionCheck + .apply("otherKids") + .test( + 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.getToManyRelation(any(), eq(model.obj), any(), any())).thenReturn( + new DataStoreIterableBuilder(Sets.newHashSet(child1, child3)).build()); // Clear the rest - model.getObject().checkFunction = (spec) -> collectionCheck.apply("otherKids").apply(spec, (original, modified) -> original.size() <= 2 && modified.size() < original.size()); + model.getObject().checkFunction = (spec) -> collectionCheck.apply("otherKids").test(spec, (original, modified) + -> original.size() <= 2 && modified.size() < original.size()); model.clearRelation("otherKids"); } @Test public void testAttrChangeSpecType() { - BiFunction, Boolean> attrCheck = (spec, checkFn) -> { - if (!(spec.getModified() instanceof String) && spec.getModified() != null) { - return false; - } - if (!"testAttr".equals(spec.getFieldName())) { - return false; - } - return checkFn.apply((String) spec.getOriginal(), (String) spec.getModified()); + BiPredicate> attrCheck = (spec, checkFn) -> { + assertTrue(spec.getModified() instanceof String || spec.getModified() == null); + assertEquals("testAttr", spec.getFieldName()); + return checkFn.test((String) spec.getOriginal(), (String) spec.getModified()); }; - PersistentResource model = bootstrapPersistentResource(new ChangeSpecModel((spec) -> attrCheck.apply(spec, (original, modified) -> (original == null) && "abc".equals(modified)))); - Assert.assertTrue(model.updateAttribute("testAttr", "abc")); + PersistentResource model = bootstrapPersistentResource(new ChangeSpecModel( + (spec) -> attrCheck.test(spec, (original, modified) -> (original == null) && "abc".equals(modified)))); + assertTrue(model.updateAttribute("testAttr", "abc")); - model.getObject().checkFunction = (spec) -> attrCheck.apply(spec, (original, modified) -> "abc".equals(original) && "replace".equals(modified)); - Assert.assertTrue(model.updateAttribute("testAttr", "replace")); + model.getObject().checkFunction = (spec) -> attrCheck.test( + spec, (original, modified) -> "abc".equals(original) && "replace".equals(modified)); + assertTrue(model.updateAttribute("testAttr", "replace")); - model.getObject().checkFunction = (spec) -> attrCheck.apply(spec, (original, modified) -> "replace".equals(original) && modified == null); - Assert.assertTrue(model.updateAttribute("testAttr", null)); + model.getObject().checkFunction = (spec) -> attrCheck.test( + spec, (original, modified) -> "replace".equals(original) && modified == null); + assertTrue(model.updateAttribute("testAttr", null)); } @Test public void testRelationChangeSpecType() { - BiFunction, Boolean> relCheck = (spec, checkFn) -> { - if (!(spec.getModified() instanceof ChangeSpecChild) && spec.getModified() != null) { - return false; - } - if (!"child".equals(spec.getFieldName())) { - return false; - } - return checkFn.apply((ChangeSpecChild) spec.getOriginal(), (ChangeSpecChild) spec.getModified()); + BiPredicate> relCheck = (spec, checkFn) -> { + assertTrue(spec.getModified() instanceof ChangeSpecChild || spec.getModified() == null); + assertEquals("child", spec.getFieldName()); + return checkFn.test((ChangeSpecChild) spec.getOriginal(), (ChangeSpecChild) spec.getModified()); }; - PersistentResource model = bootstrapPersistentResource(new ChangeSpecModel((spec) -> relCheck.apply(spec, (original, modified) -> (original == null) && new ChangeSpecChild(1).equals(modified)))); - Assert.assertTrue(model.updateRelation("child", Sets.newHashSet(bootstrapPersistentResource(new ChangeSpecChild(1))))); - model.getObject().checkFunction = (spec) -> relCheck.apply(spec, (original, modified) -> new ChangeSpecChild(1).equals(original) && new ChangeSpecChild(2).equals(modified)); - Assert.assertTrue(model.updateRelation("child", Sets.newHashSet(bootstrapPersistentResource(new ChangeSpecChild(2))))); + PersistentResource model = bootstrapPersistentResource(new ChangeSpecModel((spec) + -> relCheck.test(spec, (original, modified) + -> (original == null) && new ChangeSpecChild(1).equals(modified))), tx); + + when(tx.getToOneRelation(any(), eq(model.obj), any(), any())).thenReturn(null); + + ChangeSpecChild child1 = new ChangeSpecChild(1); + assertTrue(model.updateRelation("child", Sets.newHashSet(bootstrapPersistentResource(child1, tx)))); + when(tx.getToOneRelation(any(), eq(model.obj), any(), any())).thenReturn(child1); + + model.getObject().checkFunction = (spec) -> relCheck.test( + 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.getToOneRelation(any(), eq(model.obj), any(), any())).thenReturn(child2); - model.getObject().checkFunction = (spec) -> relCheck.apply(spec, (original, modified) -> new ChangeSpecChild(2).equals(original) && modified == null); - Assert.assertTrue(model.updateRelation("child", null)); + model.getObject().checkFunction = (spec) -> relCheck + .test(spec, (original, modified) -> new ChangeSpecChild(2).equals(original) && modified == null); + assertTrue(model.updateRelation("child", null)); } @Test - public void testEqualsAndHashcode() { - Child childWithId = newChild(1); - Child childWithoutId = newChild(0); + public void testPatchRequestScope() { + DataStoreTransaction tx = mock(DataStoreTransaction.class); + PatchRequestScope parentScope = new PatchRequestScope( + null, + "/book", + NO_VERSION, + tx, + new TestUser("1"), + UUID.randomUUID(), + null, + Collections.emptyMap(), + elideSettings); + PatchRequestScope scope = new PatchRequestScope( + parentScope.getPath(), parentScope.getJsonApiDocument(), parentScope); + // verify wrap works + assertEquals(parentScope.getUpdateStatusCode(), scope.getUpdateStatusCode()); + assertEquals(parentScope.getObjectEntityCache(), scope.getObjectEntityCache()); - PersistentResource resourceWithId = new PersistentResource<>(childWithId, goodUserScope); - PersistentResource resourceWithDifferentId = new PersistentResource<>(childWithoutId, goodUserScope); - PersistentResource resourceWithUUID = new PersistentResource<>(childWithoutId, null, "abc", goodUserScope); - PersistentResource resourceWithIdAndUUID = new PersistentResource<>(childWithId, null, "abc", goodUserScope); + Parent parent = newParent(7); - Assert.assertNotEquals(resourceWithId, resourceWithUUID); - Assert.assertNotEquals(resourceWithUUID, resourceWithId); - Assert.assertNotEquals(resourceWithId.hashCode(), resourceWithUUID.hashCode(), "Hashcodes were equal..."); + PersistentResource parentResource = new PersistentResource<>(parent, "1", scope); + parentResource.updateAttribute("firstName", "foobar"); - Assert.assertEquals(resourceWithId, resourceWithIdAndUUID); - Assert.assertEquals(resourceWithIdAndUUID, resourceWithId); - Assert.assertEquals(resourceWithId.hashCode(), resourceWithIdAndUUID.hashCode()); + 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"); + } - // Hashcode's should only ever look at UUID's if no real ID is present (i.e. object id is null or 0) - Assert.assertNotEquals(resourceWithUUID.hashCode(), resourceWithIdAndUUID.hashCode()); + @Test + public void testFilterExpressionByType() { + MultivaluedMap queryParams = new MultivaluedHashMap<>(); - Assert.assertNotEquals(resourceWithId.hashCode(), resourceWithDifferentId.hashCode()); - Assert.assertNotEquals(resourceWithId, resourceWithDifferentId); - Assert.assertNotEquals(resourceWithDifferentId, resourceWithId); - } + queryParams.add( + "filter[author.name][infix]", + "Hemingway" + ); - private PersistentResource bootstrapPersistentResource(T obj) { - User goodUser = new User(1); - DataStoreTransaction tx = mock(DataStoreTransaction.class, Answers.CALLS_REAL_METHODS); - RequestScope requestScope = new RequestScope(null, null, tx, goodUser, dictionary, null, MOCK_AUDIT_LOGGER); - return new PersistentResource<>(obj, requestScope); - } + RequestScope scope = buildRequestScope("/", mock(DataStoreTransaction.class), + new TestUser("1"), queryParams); - private RequestScope getUserScope(User user, AuditLogger auditLogger) { - return new RequestScope(null, new JsonApiDocument(), null, user, dictionary, null, auditLogger); + Optional filter = scope.getLoadFilterExpression(ClassType.of(Author.class)); + FilterPredicate predicate = (FilterPredicate) filter.get(); + assertEquals("name", predicate.getField()); + assertEquals("name", predicate.getFieldPath()); + assertEquals(Operator.INFIX, predicate.getOperator()); + assertEquals(Arrays.asList("Hemingway"), predicate.getValues()); + assertEquals("[Author].name", predicate.getPath().toString()); } - // Testing constructor, setId and non-null empty sets - private static Parent newParent(int id) { - Parent parent = new Parent(); - parent.setId(id); - parent.setChildren(new HashSet<>()); - parent.setSpouses(new HashSet<>()); - return parent; - } + @Test + public void testFilterExpressionCollection() { + MultivaluedMap queryParams = new MultivaluedHashMap<>(); - private Parent newParent(int id, Child child) { - Parent parent = new Parent(); - parent.setId(id); - parent.setChildren(Sets.newHashSet(child)); - parent.setSpouses(new HashSet<>()); - return parent; - } + queryParams.add( + "filter[book.authors.name][infix]", + "Hemingway" + ); + RequestScope scope = buildRequestScope("/", mock(DataStoreTransaction.class), new TestUser("1"), + queryParams); - private static Child newChild(int id) { - Child child = new Child(); - child.setId(id); - child.setParents(new HashSet<>()); - child.setFriends(new HashSet<>()); - return child; + Optional filter = scope.getLoadFilterExpression(ClassType.of(Book.class)); + FilterPredicate predicate = (FilterPredicate) filter.get(); + assertEquals("name", predicate.getField()); + assertEquals("authors.name", predicate.getFieldPath()); + assertEquals(Operator.INFIX, predicate.getOperator()); + assertEquals(Arrays.asList("Hemingway"), predicate.getValues()); + assertEquals("[Book].authors/[Author].name", predicate.getPath().toString()); } - private static Child newChild(int id, String name) { - Child child = newChild(id); - child.setName(name); - return child; - } - /* ChangeSpec-specific test elements */ - @Entity - @Include - @CreatePermission(any = {Role.ALL.class}) - @ReadPermission(any = {Role.ALL.class}) - @UpdatePermission(any = {Role.NONE.class}) - @DeletePermission(any = {Role.ALL.class}) - public static final class ChangeSpecModel { - @Id - public long id; + @Test + public void testSparseFields() { + MultivaluedMap queryParams = new MultivaluedHashMap<>(); - @ReadPermission(all = {Role.NONE.class}) - @UpdatePermission(all = {Role.NONE.class}) - public Function checkFunction; + queryParams.add("fields[author]", "name"); - @UpdatePermission(all = {ChangeSpecNonCollection.class}) - public String testAttr; + RequestScope scope = buildRequestScope("/", mock(DataStoreTransaction.class), + new TestUser("1"), queryParams); + Map> expected = ImmutableMap.of("author", ImmutableSet.of("name")); + assertEquals(expected, scope.getSparseFields()); + } - @UpdatePermission(all = {ChangeSpecCollection.class}) - public List testColl; + @Test + public void testEqualsAndHashcode() { + Child childWithId = newChild(1); + Child childWithoutId = newChild(0); - @OneToOne - @UpdatePermission(all = {ChangeSpecNonCollection.class}) - public ChangeSpecChild child; + RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); - @ManyToMany - @UpdatePermission(all = {ChangeSpecCollection.class}) - public List otherKids; + PersistentResource resourceWithId = new PersistentResource<>(childWithId, scope.getUUIDFor(childWithId), scope); + PersistentResource resourceWithDifferentId = + new PersistentResource<>(childWithoutId, scope.getUUIDFor(childWithoutId), scope); + PersistentResource resourceWithUUID = new PersistentResource<>(childWithoutId, "abc", scope); + PersistentResource resourceWithIdAndUUID = new PersistentResource<>(childWithId, "abc", scope); - public ChangeSpecModel(final Function checkFunction) { - this.checkFunction = checkFunction; - } - } + assertNotEquals(resourceWithUUID, resourceWithId); + assertNotEquals(resourceWithId, resourceWithUUID); + assertNotEquals(resourceWithId.hashCode(), resourceWithUUID.hashCode(), "Hashcodes were equal..."); - @Entity - @Include - @EqualsAndHashCode - @AllArgsConstructor - @CreatePermission(any = {Role.ALL.class}) - @ReadPermission(any = {Role.ALL.class}) - @UpdatePermission(any = {Role.ALL.class}) - @DeletePermission(any = {Role.ALL.class}) - @SharePermission(any = {Role.ALL.class}) - public static final class ChangeSpecChild { - @Id - public long id; - } - - public static final class ChangeSpecCollection extends OperationCheck { - - @Override - public boolean ok(Object object, com.yahoo.elide.security.RequestScope requestScope, Optional changeSpec) { - if (changeSpec.isPresent() && (object instanceof ChangeSpecModel)) { - ChangeSpec spec = changeSpec.get(); - if (!(spec.getModified() instanceof Collection)) { - return false; - } - return ((ChangeSpecModel) object).checkFunction.apply(spec); - } - throw new IllegalStateException("Something is terribly wrong :("); - } - } + assertEquals(resourceWithIdAndUUID, resourceWithId); + assertEquals(resourceWithId, resourceWithIdAndUUID); + assertEquals(resourceWithIdAndUUID.hashCode(), resourceWithId.hashCode()); - public static final class ChangeSpecNonCollection extends OperationCheck { + // Hashcode's should only ever look at UUID's if no real ID is present (i.e. object id is null or 0) + assertNotEquals(resourceWithIdAndUUID.hashCode(), resourceWithUUID.hashCode()); - @Override - public boolean ok(Object object, com.yahoo.elide.security.RequestScope requestScope, Optional changeSpec) { - if (changeSpec.isPresent() && (object instanceof ChangeSpecModel)) { - return ((ChangeSpecModel) object).checkFunction.apply(changeSpec.get()); - } - throw new IllegalStateException("Something is terribly wrong :("); - } + assertNotEquals(resourceWithDifferentId.hashCode(), resourceWithId.hashCode()); + assertNotEquals(resourceWithDifferentId, resourceWithId); + assertNotEquals(resourceWithId, resourceWithDifferentId); } } 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 1814a034bf..658f9c2cc1 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,13 +5,23 @@ */ package com.yahoo.elide.core; -import org.testng.Assert; -import org.testng.annotations.Test; +import static com.yahoo.elide.core.dictionary.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; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; +import org.junit.jupiter.api.Test; -import javax.ws.rs.core.MultivaluedHashMap; -import javax.ws.rs.core.MultivaluedMap; import java.lang.reflect.Method; import java.util.Collections; +import java.util.UUID; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; public class RequestScopeTest { @Test @@ -26,8 +36,44 @@ public void testFilterQueryParams() throws Exception { queryParams.put("bar", Collections.singletonList("foo")); MultivaluedMap filtered = (MultivaluedMap) method.invoke(null, queryParams); - Assert.assertEquals(filtered.size(), 2); - Assert.assertTrue(filtered.containsKey("filter")); - Assert.assertTrue(filtered.containsKey("filter[xyz]")); + assertEquals(2, filtered.size()); + assertTrue(filtered.containsKey("filter")); + assertTrue(filtered.containsKey("filter[xyz]")); + } + + @Test + public void testNewObjectsForInheritedTypes() throws Exception { + // NOTE: This tests that inherited types are properly accounted for during a patch extension request. + // otherwise it is possible to create an inherited type and when performing introspection on a + // superclass we will miss that the object is newly created and then we'll attempt to query + // the datastore. + + @Entity + @Include(rootLevel = false) + class MyBaseClass { + @Id + public long id; + } + + @Entity + @Include(rootLevel = false) + class MyInheritedClass extends MyBaseClass { + public String myField; + } + + EntityDictionary dictionary = EntityDictionary.builder().build(); + + dictionary.bindEntity(MyBaseClass.class); + dictionary.bindEntity(MyInheritedClass.class); + + RequestScope requestScope = new RequestScope(null, "/", NO_VERSION, null, null, null, null, null, UUID.randomUUID(), + new ElideSettingsBuilder(null) + .withEntityDictionary(dictionary) + .build()); + + String myId = "myId"; + // Test that a new inherited class is counted for base type + requestScope.setUUIDForObject(ClassType.of(MyInheritedClass.class), myId, new MyInheritedClass()); + assertNotNull(requestScope.getObjectById(ClassType.of(MyBaseClass.class), myId)); } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/ResourceLineageTest.java b/elide-core/src/test/java/com/yahoo/elide/core/ResourceLineageTest.java new file mode 100644 index 0000000000..7444b9ee3a --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/ResourceLineageTest.java @@ -0,0 +1,29 @@ +/* + * Copyright 2021, 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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import org.junit.jupiter.api.Test; + +public class ResourceLineageTest { + + @Test + public void testGetParent() { + ResourceLineage lineage = new ResourceLineage(); + assertNull(lineage.getParent()); + + PersistentResource authorResource = mock(PersistentResource.class); + PersistentResource bookResource = mock(PersistentResource.class); + + ResourceLineage longerLineage = new ResourceLineage(lineage, authorResource, "authors"); + assertEquals(authorResource, longerLineage.getParent()); + + ResourceLineage longerLongerLineage = new ResourceLineage(longerLineage, bookResource, "books"); + assertEquals(bookResource, longerLongerLineage.getParent()); + } +} 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..6557cf69d6 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/TestRequestScope.java @@ -0,0 +1,68 @@ +/* + * 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.dictionary.EntityDictionary.NO_VERSION; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.jsonapi.links.DefaultJSONApiLinks; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; + +import java.util.UUID; +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(String baseURL, + DataStoreTransaction transaction, + User user, + EntityDictionary dictionary) { + super(baseURL, null, NO_VERSION, new JsonApiDocument(), transaction, user, null, null, UUID.randomUUID(), + new ElideSettingsBuilder(null) + .withEntityDictionary(dictionary) + .withJSONApiLinks(new DefaultJSONApiLinks()) + .withJsonApiPath("/json") + .build()); + } + + public TestRequestScope(DataStoreTransaction transaction, + User user, + EntityDictionary dictionary) { + super(null, null, NO_VERSION, new JsonApiDocument(), transaction, user, null, null, UUID.randomUUID(), + new ElideSettingsBuilder(null) + .withEntityDictionary(dictionary) + .build()); + } + + public TestRequestScope(EntityDictionary dictionary, + String path, + MultivaluedMap queryParams) { + super(null, path, NO_VERSION, new JsonApiDocument(), null, null, queryParams, null, UUID.randomUUID(), + new ElideSettingsBuilder(null) + .withEntityDictionary(dictionary) + .build()); + } + + public void setQueryParams(MultivaluedMap queryParams) { + this.queryParamOverrides = queryParams; + } + + @Override + public MultivaluedMap getQueryParams() { + if (queryParamOverrides != null) { + return queryParamOverrides; + } + return super.getQueryParams(); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/audit/LogMessageImplTest.java b/elide-core/src/test/java/com/yahoo/elide/core/audit/LogMessageImplTest.java new file mode 100644 index 0000000000..32db1ee8e9 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/audit/LogMessageImplTest.java @@ -0,0 +1,150 @@ +/* + * 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.audit; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.TestUser; +import com.google.common.collect.Sets; +import example.Child; +import example.Parent; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; + +public class LogMessageImplTest { + private static transient PersistentResource childRecord; + private static transient PersistentResource friendRecord; + + @BeforeAll + public static void init() { + final EntityDictionary dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Child.class); + dictionary.bindEntity(Parent.class); + + final Child child = new Child(); + child.setId(5); + + final Parent parent = new Parent(); + parent.setId(7); + + final Child friend = new Child(); + friend.setId(9); + child.setFriends(Sets.newHashSet(friend)); + + final RequestScope requestScope = new RequestScope(null, null, NO_VERSION, null, null, + new TestUser("aaron"), null, null, + UUID.randomUUID(), + new ElideSettingsBuilder(null) + .withAuditLogger(new TestAuditLogger()) + .withEntityDictionary(dictionary) + .build()); + + final PersistentResource parentRecord = new PersistentResource<>(parent, requestScope.getUUIDFor(parent), requestScope); + childRecord = new PersistentResource<>(child, parentRecord, "children", requestScope.getUUIDFor(child), requestScope); + friendRecord = new PersistentResource<>(friend, childRecord, "friends", requestScope.getUUIDFor(friend), requestScope); + } + + @Test + public void verifyOpaqueUserExpressions() { + final String[] expressions = { "${opaqueUser.name}", "${opaqueUser.name}" }; + 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()); + } + + @Test + public void verifyObjectExpressions() { + final String[] expressions = { "${child.id}", "${parent.getId()}" }; + 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()); + } + + @Test + public void verifyListExpressions() { + final String[] expressions = { "${child[0].id}", "${child[1].id}", "${parent.getId()}" }; + final String[] expressionForDefault = { "${child.id}" }; + 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()); + } + + + @Test + public void invalidExpression() { + final String[] expressions = { "${child.id}, ${%%%}" }; + assertThrows( + InvalidSyntaxException.class, + () -> new LogMessageImpl("{0} {1}", childRecord, expressions, 1, Optional.empty()).getMessage()); + } + + @Test + public void invalidTemplate() { + final String[] expressions = { "${child.id}" }; + assertThrows( + InvalidSyntaxException.class, + () -> new LogMessageImpl("{}", childRecord, expressions, 1, Optional.empty()).getMessage()); + } + + public static class TestLoggerException extends RuntimeException { + } + + private AuditLogger testAuditLogger = new Slf4jLogger(); + + @Test + public void threadSafetyTest() { + final List exceptions = new ArrayList<>(); + final int parallelTests = 10; + + ExecutorService testThreadPool = Executors.newFixedThreadPool(parallelTests); + + for (int i = 0; i < parallelTests; i++) { + testThreadPool.submit(() -> { + try { + threadSafeLogger(); + } catch (Exception e) { + exceptions.add(e); + } + }); + } + + assertTrue(exceptions.isEmpty(), exceptions.stream().map(Throwable::getMessage).findFirst().orElse("")); + } + + public void threadSafeLogger() throws InterruptedException { + TestLoggerException testException = new TestLoggerException(); + LogMessageImpl failMessage = new LogMessageImpl("test", 0) { + @Override + public String getMessage() { + throw testException; + } + }; + + assertSame(assertThrows(TestLoggerException.class, () -> testAuditLogger.log(failMessage)), testException); + Thread.sleep(Math.floorMod(ThreadLocalRandom.current().nextInt(), 100)); + + // should not cause another exception + assertDoesNotThrow(() -> testAuditLogger.commit(), "Exception not cleared from previous logger commit"); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/audit/TestAuditLogger.java b/elide-core/src/test/java/com/yahoo/elide/core/audit/TestAuditLogger.java new file mode 100644 index 0000000000..6890177bfc --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/audit/TestAuditLogger.java @@ -0,0 +1,26 @@ +/* + * 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.audit; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class TestAuditLogger extends AuditLogger { + public TestAuditLogger() { + // clean any prior test data for this thread + super.clear(); + } + + @Override + public void commit() throws IOException { + //NOOP + } + + public List getMessages() { + return new ArrayList<>(this.MESSAGES.get()); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/datastore/DataStoreTransactionTest.java b/elide-core/src/test/java/com/yahoo/elide/core/datastore/DataStoreTransactionTest.java new file mode 100644 index 0000000000..004ee4881f --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/DataStoreTransactionTest.java @@ -0,0 +1,157 @@ +/* + * 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.datastore; + +import static com.yahoo.elide.core.type.ClassType.STRING_TYPE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.request.Attribute; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.core.type.ClassType; +import com.google.common.collect.Lists; +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.List; + +public class DataStoreTransactionTest implements DataStoreTransaction { + private static final String NAME = "name"; + private static final String NAME2 = "name2"; + private static final Attribute NAME_ATTRIBUTE = Attribute.builder().name(NAME).type(String.class).build(); + private static final String ENTITY = "entity"; + private RequestScope scope; + + @BeforeEach + public void setupMocks() { + // this will test with the default interface implementation + scope = mock(RequestScope.class); + EntityDictionary dictionary = mock(EntityDictionary.class); + + when(scope.getDictionary()).thenReturn(dictionary); + when(dictionary.getIdType(STRING_TYPE)).thenReturn(new ClassType(Long.class)); + when(dictionary.getValue(ENTITY, NAME, scope)).thenReturn(3L); + when(dictionary.getValue(ENTITY, NAME2, scope)) + .thenReturn(new DataStoreIterableBuilder(List.of(1L, 2L, 3L)).build()); + } + + @Test + public void testPreCommit() { + preCommit(scope); + verify(scope, never()).getDictionary(); + } + + @Test + public void testGetAttribute() { + Object actual = getAttribute(ENTITY, NAME_ATTRIBUTE, scope); + assertEquals(3L, actual); + verify(scope, times(1)).getDictionary(); + } + + @Test + public void testSetAttribute() { + setAttribute(ENTITY, NAME_ATTRIBUTE, scope); + verify(scope, never()).getDictionary(); + } + + @Test + public void testUpdateToOneRelation() { + updateToOneRelation(this, ENTITY, NAME, null, scope); + verify(scope, never()).getDictionary(); + } + + @Test + public void testUpdateToManyRelation() { + updateToManyRelation(this, ENTITY, NAME, null, null, scope); + verify(scope, never()).getDictionary(); + } + + @Test + public void testGetToOneRelation() { + Long actual = getToOneRelation(this, ENTITY, Relationship.builder() + .name(NAME) + .projection(EntityProjection.builder() + .type(String.class) + .build()) + .build(), scope); + assertEquals(3L, actual); + } + + @Test + public void testGetToManyRelation() { + DataStoreIterable actual = getToOneRelation(this, ENTITY, Relationship.builder() + .name(NAME2) + .projection(EntityProjection.builder() + .type(String.class) + .build()) + .build(), scope); + assertEquals( + Lists.newArrayList(new DataStoreIterableBuilder(List.of(1L, 2L, 3L)).build()), + Lists.newArrayList(actual)); + } + + @Test + public void testLoadObject() { + String string = (String) loadObject(EntityProjection.builder().type(String.class).build(), 2L, scope); + assertEquals(ENTITY, string); + } + + /** Implemented to support the interface only. No need to test these. **/ + @Override + public Object loadObject(EntityProjection entityProjection, Serializable id, RequestScope scope) { + return ENTITY; + } + + @Override + public DataStoreIterable loadObjects(EntityProjection entityProjection, RequestScope scope) { + return new DataStoreIterableBuilder(Arrays.asList(ENTITY)).build(); + } + + @Override + public void close() throws IOException { + // nothing + } + + @Override + public void save(Object entity, RequestScope scope) { + // nothing + } + + @Override + public void delete(Object entity, RequestScope scope) { + // nothing + } + + @Override + public void flush(RequestScope scope) { + // nothing + } + + @Override + public void commit(RequestScope scope) { + // nothing + } + + @Override + public void createObject(Object entity, RequestScope scope) { + // nothing + } + + @Override + public void cancel(RequestScope scope) { + //nothing + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/FilteredIteratorTest.java b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/FilteredIteratorTest.java new file mode 100644 index 0000000000..7c2891185f --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/FilteredIteratorTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.datastore.inmemory; + +import static com.yahoo.elide.core.dictionary.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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.TestRequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.type.ClassType; +import example.Book; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +public class FilteredIteratorTest { + + @Test + public void testFilteredResult() throws Exception { + + EntityDictionary dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); + + Book book1 = new Book(); + book1.setTitle("foo"); + Book book2 = new Book(); + book2.setTitle("bar"); + Book book3 = new Book(); + book3.setTitle("foobar"); + List books = List.of(book1, book2, book3); + + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); + + FilterExpression expression = + filterDialect.parse(ClassType.of(Book.class), new HashSet<>(), "title==*bar", NO_VERSION); + + RequestScope scope = new TestRequestScope(null, null, dictionary); + + Iterator bookIterator = new FilteredIterator<>(expression, scope, books.iterator()); + + assertTrue(bookIterator.hasNext()); + assertEquals("bar", bookIterator.next().getTitle()); + assertTrue(bookIterator.hasNext()); + assertEquals("foobar", bookIterator.next().getTitle()); + assertFalse(bookIterator.hasNext()); + } + + @Test + public void testEmptyResult() throws Exception { + + EntityDictionary dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); + + List books = List.of(); + + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); + + FilterExpression expression = + filterDialect.parse(ClassType.of(Book.class), new HashSet<>(), "title==*bar", NO_VERSION); + + RequestScope scope = new TestRequestScope(null, null, dictionary); + + Iterator bookIterator = new FilteredIterator<>(expression, scope, books.iterator()); + + + assertFalse(bookIterator.hasNext()); + assertThrows(NoSuchElementException.class, () -> bookIterator.next()); + } +} 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 new file mode 100644 index 0000000000..0dc3d7bec8 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java @@ -0,0 +1,625 @@ +/* + * 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.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; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.InPredicate; +import com.yahoo.elide.core.pagination.PaginationImpl; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.core.request.Sorting; +import com.yahoo.elide.core.sort.SortingImpl; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import example.Address; +import example.Author; +import example.Book; +import example.Editor; +import example.Price; +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; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class InMemoryStoreTransactionTest { + + private DataStoreTransaction wrappedTransaction = mock(DataStoreTransaction.class); + private RequestScope scope = mock(RequestScope.class); + private InMemoryStoreTransaction inMemoryStoreTransaction = new InMemoryStoreTransaction(wrappedTransaction); + private EntityDictionary dictionary; + private LinkedHashSet books = new LinkedHashSet(); + private Book book1; + private Book book2; + private Book book3; + private Publisher publisher1; + private Publisher publisher2; + private Author author1; + private Author author2; + private ElideSettings elideSettings; + + public InMemoryStoreTransactionTest() { + dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); + dictionary.bindEntity(Author.class); + dictionary.bindEntity(Editor.class); + dictionary.bindEntity(Publisher.class); + + elideSettings = new ElideSettingsBuilder(null) + .withEntityDictionary(EntityDictionary.builder().build()) + .build(); + + author1 = new Author(); + Address address1 = new Address(); + address1.setStreet1("Foo"); + author1.setHomeAddress(address1); + + author2 = new Author(); + Address address2 = new Address(); + address2.setStreet1("Bar"); + author2.setHomeAddress(address2); + + Editor editor1 = new Editor(); + editor1.setFirstName("Jon"); + editor1.setLastName("Doe"); + + Editor editor2 = new Editor(); + editor2.setFirstName("Jane"); + editor2.setLastName("Doe"); + + publisher1 = new Publisher(); + publisher1.setEditor(editor1); + + publisher2 = new Publisher(); + publisher2.setEditor(editor2); + + book1 = new Book(1, + "Book 1", + "Literary Fiction", + "English", + System.currentTimeMillis(), + Sets.newHashSet(author1), + publisher1, + Arrays.asList("Prize1"), + new Price()); + + book2 = new Book(2, + "Book 2", + "Science Fiction", + "English", + System.currentTimeMillis(), + Sets.newHashSet(author1), + publisher1, + Arrays.asList("Prize1", "Prize2"), + new Price()); + + book3 = new Book(3, + "Book 3", + "Literary Fiction", + "English", + System.currentTimeMillis(), + Sets.newHashSet(author1), + publisher2, + Arrays.asList(), + new Price()); + + books.add(book1); + books.add(book2); + books.add(book3); + + author1.setBooks(new ArrayList<>(books)); + + when(scope.getDictionary()).thenReturn(dictionary); + } + + @BeforeEach + public void resetMocks() { + reset(wrappedTransaction); + } + + @Test + public void testFullFilterPredicatePushDown() { + FilterExpression expression = + new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .build(); + + DataStoreIterable expected = new DataStoreIterableBuilder<>(books).build(); + + when(wrappedTransaction.loadObjects(eq(projection), eq(scope))) + .thenReturn(expected); + + DataStoreIterable actual = inMemoryStoreTransaction.loadObjects(projection, scope); + assertEquals(expected, actual); + + verify(wrappedTransaction, times(1)).loadObjects(eq(projection), eq(scope)); + } + + @Test + public void testFilterPredicateInMemoryOnComplexAttribute() { + FilterExpression expression = + new InPredicate(new Path(Author.class, dictionary, "homeAddress.street1"), "Foo"); + + EntityProjection projection = EntityProjection.builder() + .type(Author.class) + .filterExpression(expression) + .build(); + + DataStoreIterable filterInMemory = + new DataStoreIterableBuilder(Arrays.asList(author1, author2)).filterInMemory(true).build(); + + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(filterInMemory); + + Collection loaded = ImmutableList.copyOf(inMemoryStoreTransaction.loadObjects(projection, scope)); + + assertEquals(1, loaded.size()); + assertTrue(loaded.contains(author1)); + } + + @Test + public void testSortOnComputedAttribute() { + Map sortOrder = new HashMap<>(); + sortOrder.put("fullName", Sorting.SortOrder.desc); + + Editor editor1 = new Editor(); + editor1.setFirstName("A"); + editor1.setLastName("X"); + Editor editor2 = new Editor(); + editor2.setFirstName("B"); + editor2.setLastName("Y"); + + Sorting sorting = new SortingImpl(sortOrder, Editor.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Editor.class) + .sorting(sorting) + .build(); + + DataStoreIterable iterable = + new DataStoreIterableBuilder(Arrays.asList(editor1, editor2)).sortInMemory(false).build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); + when(wrappedTransaction.loadObjects(projectionArgument.capture(), eq(scope))).thenReturn(iterable); + + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects(projection, scope)); + + assertNull(projectionArgument.getValue().getSorting()); + assertEquals(2, loaded.size()); + + Object[] sorted = loaded.toArray(); + assertEquals(editor2, sorted[0]); + assertEquals(editor1, sorted[1]); + } + + @Test + public void testSortOnComplexAttribute() { + Map sortOrder = new HashMap<>(); + sortOrder.put("homeAddress.street1", Sorting.SortOrder.asc); + + Sorting sorting = new SortingImpl(sortOrder, Author.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Author.class) + .sorting(sorting) + .build(); + + DataStoreIterable sortInMemory = + new DataStoreIterableBuilder(Arrays.asList(author1, author2)).sortInMemory(true).build(); + + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(sortInMemory); + + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects(projection, scope)); + + assertEquals(2, loaded.size()); + + Object[] sorted = loaded.toArray(); + assertEquals(author2, sorted[0]); + assertEquals(author1, sorted[1]); + } + + @Test + 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.getToManyRelation(eq(inMemoryStoreTransaction), eq(author1), any(), eq(scope))) + .thenReturn(new DataStoreIterableBuilder<>(books).build()); + + Collection loaded = ImmutableList.copyOf((Iterable) inMemoryStoreTransaction.getToManyRelation( + inMemoryStoreTransaction, author1, relationship, scope)); + + verify(wrappedTransaction, times(1)).getToManyRelation( + eq(inMemoryStoreTransaction), + eq(author1), + 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 testGetToOneRelationship() { + + Relationship relationship = Relationship.builder() + .projection(EntityProjection.builder() + .type(Book.class) + .build()) + .name("publisher") + .alias("publisher") + .build(); + + when(wrappedTransaction.getToOneRelation(eq(inMemoryStoreTransaction), eq(book1), any(), eq(scope))) + .thenReturn(publisher1); + + Publisher loaded = inMemoryStoreTransaction.getToOneRelation( + inMemoryStoreTransaction, book1, relationship, scope); + + verify(wrappedTransaction, times(1)).getToOneRelation( + eq(inMemoryStoreTransaction), + eq(book1), + eq(relationship), + eq(scope)); + + assertEquals(publisher1, loaded); + } + + @Test + public void testDataStoreRequiresTotalInMemoryFilter() { + FilterExpression expression = + new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .build(); + + DataStoreIterable filterInMemory = new DataStoreIterableBuilder(books).filterInMemory(true).build(); + + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(filterInMemory); + + Collection loaded = ImmutableList.copyOf(inMemoryStoreTransaction.loadObjects(projection, scope)); + + verify(wrappedTransaction, times(1)).loadObjects( + any(EntityProjection.class), + eq(scope)); + + assertEquals(2, loaded.size()); + assertTrue(loaded.contains(book1)); + assertTrue(loaded.contains(book3)); + } + + + @Test + public void testSortingPushDown() { + Map sortOrder = new HashMap<>(); + sortOrder.put("title", Sorting.SortOrder.asc); + + Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .sorting(sorting) + .build(); + + DataStoreIterable expected = new DataStoreIterableBuilder<>(books).build(); + when(wrappedTransaction.loadObjects(any(), eq(scope))) + .thenReturn(expected); + + DataStoreIterable actual = inMemoryStoreTransaction.loadObjects(projection, scope); + + verify(wrappedTransaction, times(1)).loadObjects( + eq(projection), + eq(scope)); + + assertEquals(expected, actual); + } + + @Test + public void testDataStoreRequiresInMemorySorting() { + Map sortOrder = new HashMap<>(); + sortOrder.put("title", Sorting.SortOrder.desc); + + Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .sorting(sorting) + .build(); + + DataStoreIterable sortInMemory = new DataStoreIterableBuilder(books).sortInMemory(true).build(); + + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(sortInMemory); + + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( + projection, + scope)); + + verify(wrappedTransaction, times(1)).loadObjects( + any(EntityProjection.class), + eq(scope)); + + assertEquals(3, loaded.size()); + + List bookTitles = loaded.stream().map((o) -> ((Book) o).getTitle()).collect(Collectors.toList()); + assertEquals(bookTitles, Lists.newArrayList("Book 3", "Book 2", "Book 1")); + } + + @Test + public void testFilteringRequiresInMemorySorting() { + FilterExpression expression = + new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); + + Map sortOrder = new HashMap<>(); + sortOrder.put("title", Sorting.SortOrder.desc); + + Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .sorting(sorting) + .build(); + + DataStoreIterable filterInMemory = new DataStoreIterableBuilder(books).filterInMemory(true).build(); + + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(filterInMemory); + + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( + projection, + scope)); + + verify(wrappedTransaction, times(1)).loadObjects( + any(EntityProjection.class), + eq(scope)); + + assertEquals(2, loaded.size()); + + List bookTitles = loaded.stream().map((o) -> ((Book) o).getTitle()).collect(Collectors.toList()); + assertEquals(Lists.newArrayList("Book 3", "Book 1"), bookTitles); + } + + @Test + public void testPaginationPushDown() { + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 1, 10, 10, false, false); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .pagination(pagination) + .build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); + + when(wrappedTransaction.loadObjects(any(), eq(scope))) + .thenReturn(new DataStoreIterableBuilder<>(books).build()); + + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( + projection, + scope)); + + verify(wrappedTransaction, times(1)).loadObjects( + projectionArgument.capture(), + eq(scope)); + + assertEquals(pagination, projectionArgument.getValue().getPagination()); + assertEquals(3, loaded.size()); + } + + @Test + public void testDataStoreRequiresInMemoryPagination() { + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 2, 10, 10, false, false); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .pagination(pagination) + .build(); + + DataStoreIterable paginateInMemory = new DataStoreIterableBuilder(books).paginateInMemory(true).build(); + + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(paginateInMemory); + + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( + projection, + scope)); + + verify(wrappedTransaction, times(1)).loadObjects( + any(EntityProjection.class), + eq(scope)); + + assertEquals(2, loaded.size()); + assertTrue(loaded.contains(book1)); + assertTrue(loaded.contains(book2)); + } + + @Test + public void testFilteringRequiresInMemoryPagination() { + FilterExpression expression = + new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); + + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 2, 10, 10, true, false); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .filterExpression(expression) + .pagination(pagination) + .build(); + + DataStoreIterable filterInMemory = new DataStoreIterableBuilder(books).filterInMemory(true).build(); + + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(filterInMemory); + + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( + projection, + scope)); + + verify(wrappedTransaction, times(1)).loadObjects( + any(EntityProjection.class), + eq(scope)); + + assertEquals(2, loaded.size()); + assertTrue(loaded.contains(book1)); + assertTrue(loaded.contains(book3)); + assertEquals(2, pagination.getPageTotals()); + } + + @Test + public void testSortingRequiresInMemoryPagination() { + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 3, 10, 10, true, false); + + Map sortOrder = new HashMap<>(); + sortOrder.put("title", Sorting.SortOrder.desc); + + Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Book.class) + .sorting(sorting) + .pagination(pagination) + .build(); + + DataStoreIterable sortInMemory = new DataStoreIterableBuilder(books).sortInMemory(true).build(); + + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(sortInMemory); + + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( + projection, + scope)); + + verify(wrappedTransaction, times(1)).loadObjects( + any(EntityProjection.class), + eq(scope)); + + assertEquals(3, loaded.size()); + List bookTitles = loaded.stream().map((o) -> ((Book) o).getTitle()).collect(Collectors.toList()); + assertEquals(Lists.newArrayList("Book 3", "Book 2", "Book 1"), bookTitles); + assertEquals(3, pagination.getPageTotals()); + } + + @Test + public void testGetProperty() { + when(wrappedTransaction.getProperty(any())).thenReturn(1); + + Integer result = inMemoryStoreTransaction.getProperty("foo"); + + verify(wrappedTransaction, times(1)).getProperty(eq("foo")); + + assertEquals(1, result); + } + + @Test + public void testInMemoryDataStore() { + HashMapDataStore wrapped = new HashMapDataStore(DefaultClassScanner.getInstance(), Book.class.getPackage()); + InMemoryDataStore store = new InMemoryDataStore(wrapped); + DataStoreTransaction tx = store.beginReadTransaction(); + assertEquals(InMemoryStoreTransaction.class, tx.getClass()); + + assertEquals(wrapped, wrapped.getDataStore()); + + // extract class names from DataStore string + String tos = store.toString() + .replace("Data store contents", "") + .replace("Table ClassType{cls=class", "").replace("} contents", "") + .replace("Wrapped:[", "").replace("]", "") + .replace("\n\n", ",") + .replace(" ", "").replace("\n", ""); + + // make sure count is correct + assertEquals(ImmutableSet.copyOf(new String[] { + "example.Author", + "example.Book", + "example.Child", + "example.CoerceBean", + "example.ComputedBean", + "example.Editor", + "example.FieldAnnotations", + "example.FirstClassFields", + "example.FunWithPermissions", + "example.Invoice", + "example.Job", + "example.Left", + "example.LineItem", + "example.MapColorShape", + "example.NoDeleteEntity", + "example.NoReadEntity", + "example.NoShareEntity", + "example.NoUpdateEntity", + "example.Parent", + "example.Post", + "example.PrimitiveId", + "example.Publisher", + "example.Right", + "example.StringId", + "example.UpdateAndCreate", + "example.User", + "example.Company", + "example.models.generics.Employee", + "example.models.generics.Manager", + "example.models.generics.Overlord", + "example.models.generics.Peon", + "example.models.packageinfo.IncludedPackageLevel", + "example.models.packageinfo.included.IncludedSubPackage", + "example.models.triggers.Invoice", + "example.models.versioned.BookV2", + "example.nontransferable.ContainerWithPackageShare", + "example.nontransferable.NoTransferBiDirectional", + "example.nontransferable.ShareableWithPackageShare", + "example.nontransferable.StrictNoTransfer", + "example.nontransferable.Untransferable" + }), ImmutableSet.copyOf(tos.split(","))); + } +} 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 new file mode 100644 index 0000000000..9983adad9c --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapperTest.java @@ -0,0 +1,207 @@ +/* + * 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.datastore.wrapped; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +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.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.request.Attribute; +import org.junit.jupiter.api.Test; + +public class TransactionWrapperTest { + + private static class TestTransactionWrapper extends TransactionWrapper { + public TestTransactionWrapper(DataStoreTransaction wrapped) { + super(wrapped); + } + } + + @Test + public void testPreCommit() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.preCommit(null); + verify(wrapped, times(1)).preCommit(any()); + } + + @Test + public void testClose() throws Exception { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.close(); + + verify(wrapped, times(1)).close(); + } + + @Test + public void testLoadObjects() throws Exception { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + DataStoreIterable expected = mock(DataStoreIterable.class); + when(wrapped.loadObjects(any(), any())).thenReturn(expected); + + Iterable actual = wrapper.loadObjects(null, null); + + verify(wrapped, times(1)).loadObjects(any(), any()); + assertEquals(expected, actual); + } + + @Test + public void testCreateObject() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.createObject(null, null); + + verify(wrapped, times(1)).createObject(any(), any()); + } + + @Test + public void testCommit() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.commit(null); + + verify(wrapped, times(1)).commit(any()); + } + + @Test + public void testFlush() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.flush(null); + + verify(wrapped, times(1)).flush(any()); + } + + @Test + public void testDelete() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.delete(null, null); + + verify(wrapped, times(1)).delete(any(), any()); + } + + @Test + public void testSave() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.save(null, null); + + verify(wrapped, times(1)).save(any(), any()); + } + + @Test + public void testGetAttribute() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + when(wrapped.getAttribute(any(), isA(Attribute.class), any())).thenReturn(1L); + + Object actual = wrapper.getAttribute(null, Attribute.builder().name("foo").type(String.class).build(), null); + + verify(wrapped, times(1)).getAttribute(any(), isA(Attribute.class), any()); + assertEquals(1L, actual); + } + + @Test + public void testSetAttribute() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.setAttribute(null, null, null); + + verify(wrapped, times(1)).setAttribute(any(), any(), any()); + } + + @Test + public void testUpdateToOneRelation() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.updateToOneRelation(null, null, null, null, null); + + verify(wrapped, times(1)).updateToOneRelation(any(), any(), any(), any(), any()); + } + + @Test + public void testUpdateToManyRelation() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + wrapper.updateToManyRelation(null, null, null, null, null, null); + + verify(wrapped, times(1)).updateToManyRelation(any(), any(), any(), any(), any(), any()); + } + + @Test + public void testGetToManyRelation() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + DataStoreIterable expected = mock(DataStoreIterable.class); + when(wrapped.getToManyRelation(any(), any(), any(), any())).thenReturn(expected); + + DataStoreIterable actual = wrapper.getToManyRelation(null, null, null, null); + + verify(wrapped, times(1)).getToManyRelation(any(), any(), any(), any()); + assertEquals(expected, actual); + } + + @Test + public void testGetToOneRelation() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + when(wrapped.getToOneRelation(any(), any(), any(), any())).thenReturn(1L); + + Long actual = wrapper.getToOneRelation(null, null, null, null); + + verify(wrapped, times(1)).getToOneRelation(any(), any(), any(), any()); + assertEquals(1L, actual); + } + + @Test + public void testLoadObject() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + when(wrapped.loadObject(any(), any(), any())).thenReturn(1L); + + Object actual = wrapper.loadObject(null, null, null); + + verify(wrapped, times(1)).loadObject(any(), any(), any()); + assertEquals(1L, actual); + } + + @Test + public void testGetProperty() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + when(wrapped.getProperty(any())).thenReturn(1L); + + Object actual = wrapper.getProperty("foo"); + + verify(wrapped, times(1)).getProperty(any()); + assertEquals(1L, actual); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityBindingTest.java b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityBindingTest.java new file mode 100644 index 0000000000..1706d0841f --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityBindingTest.java @@ -0,0 +1,102 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.dictionary; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.core.type.AccessibleObject; +import com.yahoo.elide.core.type.ClassType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.stream.Collectors; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.MapsId; +import javax.persistence.OneToOne; + +public class EntityBindingTest { + private static EntityBinding entityBinding; + + @BeforeAll + public static void init() { + entityBinding = new EntityBinding( + null, ClassType.of(ChildClass.class), "childBinding"); + } + + @Test + public void testGetAllFields() throws Exception { + List allFields = entityBinding.getAllFields().stream() + .map(AccessibleObject::getName) + .collect(Collectors.toList()); + + assertEquals(2, allFields.size()); + assertEquals(allFields.get(0), "childField"); + assertEquals(allFields.get(1), "parentField"); + } + + @Test + public void testIdField() throws Exception { + AccessibleObject idField = entityBinding.getIdField(); + assertEquals(idField, ClassType.of(ParentClass.class).getDeclaredField("parentField")); + } + + @Test + public void testIdGeneratedFalseWhenNoAnnotations() throws Exception { + assertFalse(entityBinding.isIdGenerated()); + } + + @Test + public void testIdGeneratedTrueWhenGenerateValue() throws Exception { + final EntityBinding eb = new EntityBinding(null, + ClassType.of(GeneratedValueClass.class), "testBinding"); + assertTrue(eb.isIdGenerated()); + } + + @Test + public void testIdGeneratedTrueWhenMapsId() throws Exception { + final EntityBinding eb = new EntityBinding(null, + ClassType.of(MapsIdClass.class), "testBinding"); + assertTrue(eb.isIdGenerated()); + } + + @Test + public void testIdGeneratedFalseWhenBadMapsId() throws Exception { + final EntityBinding eb = new EntityBinding(null, + ClassType.of(BadMapsIdClass.class), "testBinding"); + assertFalse(eb.isIdGenerated()); + } + + private class ParentClass { + @Id + String parentField; + + public void ignoredMethod() { } + } + + private class ChildClass extends ParentClass { + String childField; + } + + private class GeneratedValueClass { + @Id + @GeneratedValue + String id; + } + + private class MapsIdClass extends ParentClass { + @OneToOne + @MapsId + public ParentClass parent; + } + + private class BadMapsIdClass extends ParentClass { + @MapsId + public ParentClass parent; + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityDictionaryTest.java b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityDictionaryTest.java new file mode 100644 index 0000000000..ea52e637fd --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityDictionaryTest.java @@ -0,0 +1,1180 @@ +/* + * 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.dictionary; + +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; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +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.ReadPermission; +import com.yahoo.elide.annotation.SecurityCheck; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.exceptions.InvalidAttributeException; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.lifecycle.LifeCycleHook; +import com.yahoo.elide.core.security.checks.FilterExpressionCheck; +import com.yahoo.elide.core.security.checks.UserCheck; +import com.yahoo.elide.core.security.checks.prefab.Collections.AppendOnly; +import com.yahoo.elide.core.security.checks.prefab.Collections.RemoveOnly; +import com.yahoo.elide.core.security.checks.prefab.Role; +import com.yahoo.elide.core.type.AccessibleObject; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.core.utils.coerce.converters.ISO8601DateSerde; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import example.Address; +import example.Author; +import example.Book; +import example.Child; +import example.CoerceBean; +import example.Editor; +import example.FieldAnnotations; +import example.FunWithPermissions; +import example.GeoLocation; +import example.Job; +import example.Left; +import example.Parent; +import example.Price; +import example.Publisher; +import example.Right; +import example.StringId; +import example.User; +import example.models.generics.Employee; +import example.models.generics.Manager; +import example.models.packageinfo.ExcludedPackageLevel; +import example.models.packageinfo.IncludedPackageLevel; +import example.models.packageinfo.excluded.ExcludedSubPackage; +import example.models.packageinfo.included.ExcludedBySuperClass; +import example.models.packageinfo.included.IncludedSubPackage; +import example.models.versioned.BookV2; +import example.nontransferable.NoTransferBiDirectional; +import example.nontransferable.StrictNoTransfer; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.Annotation; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +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(name = "friend") + private class Friend extends Child { + } + + public EntityDictionaryTest() { + super( + Collections.emptyMap(), //checks + Collections.emptyMap(), //role Checks + DEFAULT_INJECTOR, + CoerceUtil::lookup, + Collections.emptySet(), + DefaultClassScanner.getInstance() + ); + init(); + } + + private void init() { + bindEntity(FunWithPermissions.class); + bindEntity(Parent.class); + bindEntity(Child.class); + bindEntity(Publisher.class); + bindEntity(User.class); + bindEntity(Left.class); + bindEntity(Right.class); + bindEntity(StringId.class); + bindEntity(Friend.class); + bindEntity(FieldAnnotations.class); + bindEntity(Manager.class); + bindEntity(Employee.class); + bindEntity(Job.class); + bindEntity(NoId.class); + bindEntity(BookV2.class); + bindEntity(Book.class); + bindEntity(Author.class); + bindEntity(Editor.class); + bindEntity(IncludedPackageLevel.class); + bindEntity(IncludedSubPackage.class); + bindEntity(ExcludedPackageLevel.class); + bindEntity(ExcludedSubPackage.class); + bindEntity(ExcludedBySuperClass.class); + bindEntity(StrictNoTransfer.class); + bindEntity(NoTransferBiDirectional.class); + + checkNames.forcePut("user has all access", Role.ALL.class); + } + + @Test + public void testGetInjector() { + assertNotNull(getInjector()); + } + + @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)); + } + + @SecurityCheck("User is Admin") + public static class Bar extends UserCheck { + + @Override + public boolean ok(com.yahoo.elide.core.security.User user) { + return false; + } + } + + @Test + public void testBindingNoExcludeSet() { + + EntityDictionary testDictionary = EntityDictionary.builder().build(); + testDictionary.bindEntity(Employee.class); + // Finds the Binding + assertNotNull(testDictionary.entityBindings.get(ClassType.of(Employee.class))); + } + + @Test + public void testCheckScan() { + + EntityDictionary testDictionary = EntityDictionary.builder().build(); + testDictionary.scanForSecurityChecks(); + + assertEquals("User is Admin", testDictionary.getCheckIdentifier(Bar.class)); + } + + @SecurityCheck("Filter Expression Injection Test") + public static class Foo extends FilterExpressionCheck { + + @Inject + Long testLong; + + @Override + public FilterExpression getFilterExpression(Type entityClass, + com.yahoo.elide.core.security.RequestScope requestScope) { + assertEquals(testLong, 123L); + return null; + } + } + + @Test + public void testCheckInjection() { + EntityDictionary testDictionary = EntityDictionary.builder() + .injector(new Injector() { + @Override + public void inject(Object entity) { + if (entity instanceof Foo) { + ((Foo) entity).testLong = 123L; + } + } + }) + .build(); + testDictionary.scanForSecurityChecks(); + + assertEquals("Filter Expression Injection Test", testDictionary.getCheckIdentifier(Foo.class)); + } + + @Test + public void testSerdeId() { + + @Include(rootLevel = false) + class EntityWithDateId { + @Id + private Date id; + } + + EntityDictionary testDictionary = new EntityDictionary( + new HashMap<>(), + null, + DEFAULT_INJECTOR, + unused -> new ISO8601DateSerde(), + Collections.emptySet(), + DefaultClassScanner.getInstance()); + + testDictionary.bindEntity(EntityWithDateId.class); + + EntityWithDateId testModel = new EntityWithDateId(); + testModel.id = new Date(0); + assertEquals("1970-01-01T00:00Z", testDictionary.getId(testModel)); + } + + @Test + public void testGetAttributeOrRelationAnnotation() { + String[] fields = {"field1", "field2", "field3", "relation1", "relation2"}; + Annotation annotation; + for (String field : fields) { + annotation = getAttributeOrRelationAnnotation(ClassType.of(FunWithPermissions.class), ReadPermission.class, field); + assertTrue(annotation instanceof ReadPermission, "Every field should return a ReadPermission annotation"); + } + } + + @Test + public void testHasAnnotation() { + assertTrue(hasAnnotation(ClassType.of(Book.class), Include.class)); + assertTrue(hasAnnotation(ClassType.of(Book.class), Transient.class)); + assertFalse(hasAnnotation(ClassType.of(Book.class), Exclude.class)); + } + + @Test + public void testBindingTriggerPriorToBindingEntityClass1() { + @Entity + @Include(rootLevel = false) + class Foo2 { + @Id + private long id; + + private int bar; + } + + LifeCycleHook trigger = mock(LifeCycleHook.class); + + bindTrigger(Foo2.class, "bar", UPDATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY, trigger); + assertEquals(1, getAllExposedFields(ClassType.of(Foo2.class)).size()); + } + + @Test + public void testBindingTriggerPriorToBindingEntityClass2() { + @Entity + @Include(rootLevel = false) + class Foo3 { + @Id + private long id; + + private int bar; + } + + LifeCycleHook trigger = mock(LifeCycleHook.class); + + bindTrigger(Foo3.class, UPDATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY, trigger, true); + assertEquals(1, getAllExposedFields(ClassType.of(Foo3.class)).size()); + } + + @Test + public void testBindingTriggerPriorToBindingEntityClass3() { + @Entity + @Include(rootLevel = false) + class Foo4 { + @Id + private long id; + + private int bar; + } + + LifeCycleHook trigger = mock(LifeCycleHook.class); + + bindTrigger(Foo4.class, UPDATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY, trigger, false); + assertEquals(1, getAllExposedFields(ClassType.of(Foo4.class)).size()); + } + + @Test + public void testJPAFieldLevelAccess() { + @Entity + @Include(rootLevel = false) + class FieldLevelTest { + @Id + private long id; + + private int bar; + + @Exclude + private int excluded; + + @Transient + @ComputedAttribute + private int computedField; + + @Transient + @ComputedAttribute + public int getComputedProperty() { + return 1; + } + + public void setComputedProperty() { + // NOOP + } + } + bindEntity(FieldLevelTest.class); + + assertEquals(AccessType.FIELD, getAccessType(ClassType.of(FieldLevelTest.class))); + + List fields = getAllExposedFields(ClassType.of(FieldLevelTest.class)); + assertEquals(3, fields.size()); + assertTrue(fields.contains("bar")); + assertTrue(fields.contains("computedField")); + assertTrue(fields.contains("computedProperty")); + } + + @Test + public void testJPAPropertyLevelAccess() { + @Entity + @Include(rootLevel = false) + class PropertyLevelTest { + private long id; + + private int excluded; + public int bar; + + + @Exclude + public int getExcluded() { + return excluded; + } + + public void setExcluded(int unused) { + //noop + } + + @Transient + @ComputedAttribute + private int computedField; + + @Transient + @ComputedAttribute + public int getComputedProperty() { + return 1; + } + + public void setComputedProperty() { + //NOOP + } + } + bindEntity(PropertyLevelTest.class); + + assertEquals(AccessType.PROPERTY, getAccessType(ClassType.of(PropertyLevelTest.class))); + + List fields = getAllExposedFields(ClassType.of(PropertyLevelTest.class)); + assertEquals(2, fields.size()); + assertTrue(fields.contains("bar")); + assertTrue(fields.contains("computedProperty")); + } + + + @Test + public void testGetParameterizedType() { + Type type; + + FunWithPermissions fun = new FunWithPermissions(); + + type = getParameterizedType(fun, "relation2"); + assertEquals(ClassType.of(Child.class), type, "A set of Child objects should return Child.class"); + + type = getParameterizedType(fun, "relation3"); + assertEquals(ClassType.of(Child.class), type, "A Child object should return Child.class"); + + assertEquals( + ClassType.of(FieldAnnotations.class), + getParameterizedType(ClassType.of(FieldAnnotations.class), "children"), + "getParameterizedType return the type of a private field relationship"); + + assertEquals( + ClassType.of(Child.class), + getParameterizedType(ClassType.of(Parent.class), "children"), + "getParameterizedType returns the type of relationship fields"); + + assertEquals( + ClassType.of(Employee.class), + getParameterizedType(ClassType.of(Manager.class), "minions"), + "getParameterizedType returns the correct generic type of a to-many relationship"); + } + + @Test + public void testIsGeneratedId() { + @Include(rootLevel = false) + class GeneratedIdModel { + @Id + @GeneratedValue + private long id; + + } + + @Include(rootLevel = false) + class NonGeneratedIdModel { + @Id + private long id; + + } + bindEntity(GeneratedIdModel.class); + bindEntity(NonGeneratedIdModel.class); + + assertTrue(isIdGenerated(ClassType.of(GeneratedIdModel.class))); + assertFalse(isIdGenerated(ClassType.of(NonGeneratedIdModel.class))); + } + + @Test + public void testHiddenFields() { + @Include + class Model { + @Id + private long id; + + private String field1; + private String field2; + } + + bindEntity(Model.class, (field) -> field.getName().equals("field1")); + + Type modelType = ClassType.of(Model.class); + + assertEquals(List.of("field2"), getAllExposedFields(modelType)); + + EntityBinding binding = getEntityBinding(modelType); + assertEquals(List.of("id", "field1", "field2"), binding.getAllFields().stream() + .map(AccessibleObject::getName) + .collect(Collectors.toList())); + } + + @Test + public void testGetInverseRelationshipOwningSide() { + assertEquals( + "parents", + getRelationInverse(ClassType.of(Parent.class), "children"), + "The inverse relationship of children should be parents"); + } + + @Test + public void testGetInverseRelationshipOwnedSide() { + assertEquals( + "children", + getRelationInverse(ClassType.of(Child.class), "parents"), + "The inverse relationship of children should be parents"); + } + + @Test + public void testComputedAttributeIsExposed() { + List attributes = getAttributes(ClassType.of(User.class)); + assertTrue(attributes.contains("password")); + } + + @Test + public void testExcludedAttributeIsNotExposed() { + List attributes = getAttributes(ClassType.of(User.class)); + assertFalse(attributes.contains("reversedPassword")); + } + + @Test + public void testDetectCascadeRelations() { + assertFalse(cascadeDeletes(ClassType.of(FunWithPermissions.class), "relation1")); + assertFalse(cascadeDeletes(ClassType.of(FunWithPermissions.class), "relation2")); + assertTrue(cascadeDeletes(ClassType.of(FunWithPermissions.class), "relation3")); + assertFalse(cascadeDeletes(ClassType.of(FunWithPermissions.class), "relation4")); + assertFalse(cascadeDeletes(ClassType.of(FunWithPermissions.class), "relation5")); + } + + @Test + public void testGetIdAnnotations() throws Exception { + + Collection expectedAnnotationClasses = Arrays.asList(new Class[]{Id.class, GeneratedValue.class}); + Collection actualAnnotationsClasses = getIdAnnotations(new Parent()).stream() + .map(Annotation::annotationType) + .collect(Collectors.toList()); + + assertEquals(actualAnnotationsClasses, expectedAnnotationClasses, + "getIdAnnotations returns annotations on the ID field of the given class"); + } + + @Test + public void testGetIdAnnotationsNoId() throws Exception { + + Collection expectedAnnotation = Collections.emptyList(); + Collection actualAnnotations = getIdAnnotations(new NoId()); + + assertEquals(actualAnnotations, expectedAnnotation, + "getIdAnnotations returns an empty collection if there is no ID field for given class"); + } + + @Include(rootLevel = false) + class NoId { + + } + + @Test + public void testGetIdAnnotationsSubClass() throws Exception { + + Collection expectedAnnotationClasses = Arrays.asList(new Class[]{Id.class, GeneratedValue.class}); + Collection actualAnnotationsClasses = getIdAnnotations(new Friend()).stream() + .map(Annotation::annotationType) + .collect(Collectors.toList()); + + assertEquals(actualAnnotationsClasses, expectedAnnotationClasses, + "getIdAnnotations returns annotations on the ID field when defined in a super class"); + } + + @Test + public void testIsSharableTrue() throws Exception { + assertTrue(isTransferable(ClassType.of(Right.class))); + assertFalse(isStrictNonTransferable(ClassType.of(Right.class))); + } + + @Test + public void testIsSharableFalse() throws Exception { + assertFalse(isTransferable(ClassType.of(Left.class))); + assertFalse(isStrictNonTransferable(ClassType.of(Left.class))); + } + + @Test + public void testIsStrictNonTransferable() throws Exception { + assertTrue(isStrictNonTransferable(ClassType.of(StrictNoTransfer.class))); + assertFalse(isStrictNonTransferable(ClassType.of(NoTransferBiDirectional.class))); + } + + @Test + public void testGetIdType() throws Exception { + assertEquals(getIdType(ClassType.of(Parent.class)), ClassType.of(long.class), + "getIdType returns the type of the ID field of the given class"); + + assertEquals(getIdType(ClassType.of(StringId.class)), ClassType.of(String.class), + "getIdType returns the type of the ID field of the given class"); + + assertNull(getIdType(ClassType.of(NoId.class)), + "getIdType returns null if ID field is missing"); + + assertEquals(getIdType(ClassType.of(Friend.class)), ClassType.of(long.class), + "getIdType returns the type of the ID field when defined in a super class"); + } + + @Test + public void testGetType() throws Exception { + assertEquals( + ClassType.of(Long.class), + getType(ClassType.of(FieldAnnotations.class), "id"), + "getType returns the type of the ID field of the given class"); + + assertEquals( + ClassType.of(long.class), + getType(ClassType.of(FieldAnnotations.class), "publicField"), + "getType returns the type of attribute when Column annotation is on a field"); + + assertEquals( + ClassType.of(Boolean.class), + getType(ClassType.of(FieldAnnotations.class), "privateField"), + "getType returns the type of attribute when Column annotation is on a getter"); + + assertNull(getType(ClassType.of(FieldAnnotations.class), "missingField"), + "getId returns null if attribute is missing" + ); + + assertEquals( + ClassType.of(FieldAnnotations.class), + getType(ClassType.of(FieldAnnotations.class), "parent"), + "getType return the type of a private field relationship"); + + assertEquals( + ClassType.of(Set.class), + getType(ClassType.of(FieldAnnotations.class), "children"), + "getType return the type of a private field relationship"); + + assertEquals( + ClassType.of(Set.class), + getType(ClassType.of(Parent.class), "children"), + "getType returns the type of relationship fields"); + + assertEquals(ClassType.of(String.class), getType(ClassType.of(Friend.class), "name"), + "getType returns the type of attribute when defined in a super class"); + + assertEquals(ClassType.of(Manager.class), getType(ClassType.of(Employee.class), "boss"), + "getType returns the correct generic type of a to-one relationship"); + + assertEquals(ClassType.of(Set.class), getType(ClassType.of(Manager.class), "minions"), + "getType returns the correct generic type of a to-many relationship"); + + // ID is "id" + assertEquals(ClassType.of(long.class), getType(ClassType.of(Parent.class), "id"), + "getType returns the type of surrogate key"); + + // ID is not "id" + assertEquals(ClassType.of(Long.class), getType(ClassType.of(Job.class), "jobId"), + "getType returns the type of surrogate key"); + assertEquals(ClassType.of(Long.class), getType(ClassType.of(Job.class), "id"), + "getType returns the type of surrogate key"); + assertEquals(ClassType.of(String.class), getType(ClassType.of(StringId.class), "surrogateKey"), + "getType returns the type of surrogate key"); + assertEquals(ClassType.of(String.class), getType(ClassType.of(StringId.class), "id"), + "getType returns the type of surrogate key"); + } + + @Test + public void testGetTypUnknownEntityException() { + assertThrows(IllegalArgumentException.class, () -> getType(ClassType.of(Object.class), "id")); + } + + @Test + public void testNoExcludedFieldsReturned() { + List attrs = getAttributes(ClassType.of(Child.class)); + List rels = getRelationships(ClassType.of(Child.class)); + assertTrue(!attrs.contains("excludedEntity") && !attrs.contains("excludedRelationship") + && !attrs.contains("excludedEntityList")); + assertTrue(!rels.contains("excludedEntity") && !rels.contains("excludedRelationship") + && !rels.contains("excludedEntityList")); + } + + public interface BadInterface { + } + + @Test + public void testBadInterface() { + assertThrows(IllegalArgumentException.class, () -> getEntityBinding(ClassType.of(BadInterface.class))); + } + + @Test + public void testEntityInheritanceBinding() { + @Entity + @Include(rootLevel = false) + class SuperclassBinding { + @Id + private long id; + } + + class SubclassBinding extends SuperclassBinding { + } + + class SubsubclassBinding extends SubclassBinding { + } + + bindEntity(SuperclassBinding.class); + bindEntity(SubclassBinding.class); + bindEntity(SubsubclassBinding.class); + + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SubclassBinding.class)).entityClass); + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SuperclassBinding.class)).entityClass); + + assertEquals(ClassType.of(SuperclassBinding.class), lookupEntityClass(ClassType.of(SuperclassBinding.class))); + assertEquals(ClassType.of(SuperclassBinding.class), lookupEntityClass(ClassType.of(SubclassBinding.class))); + assertEquals(ClassType.of(SuperclassBinding.class), lookupEntityClass(ClassType.of(SubsubclassBinding.class))); + + assertNull(getEntityClass("subclassBinding", NO_VERSION)); + assertEquals(ClassType.of(SuperclassBinding.class), getEntityClass("superclassBinding", NO_VERSION)); + + assertEquals("superclassBinding", getJsonAliasFor(ClassType.of(SubclassBinding.class))); + assertEquals("superclassBinding", getJsonAliasFor(ClassType.of(SuperclassBinding.class))); + } + + @Test + public void testEntityInheritanceBindingOverride() { + @Entity + @Include(rootLevel = false) + class SuperclassBinding { + @Id + private long id; + } + + class SubclassBinding extends SuperclassBinding { + } + + @Entity + @Include(rootLevel = false) + class SubsubclassBinding extends SubclassBinding { + @Id + private long id; + } + + bindEntity(SuperclassBinding.class); + bindEntity(SubsubclassBinding.class); + + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SuperclassBinding.class)).entityClass); + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SubclassBinding.class)).entityClass); + assertEquals(ClassType.of(SubsubclassBinding.class), getEntityBinding(ClassType.of(SubsubclassBinding.class)).entityClass); + + assertEquals(ClassType.of(SuperclassBinding.class), lookupEntityClass(ClassType.of(SuperclassBinding.class))); + assertEquals(ClassType.of(SuperclassBinding.class), lookupEntityClass(ClassType.of(SubclassBinding.class))); + assertEquals(ClassType.of(SubsubclassBinding.class), lookupEntityClass(ClassType.of(SubsubclassBinding.class))); + } + + @Test + public void testMissingEntityBinding() { + @Entity + class SuperclassBinding { + @Id + private long id; + } + + bindEntity(SuperclassBinding.class); + + assertNull(getEntityBinding(ClassType.of(SuperclassBinding.class)).entityClass); + assertEquals(ClassType.of(SuperclassBinding.class), lookupEntityClass(ClassType.of(SuperclassBinding.class))); + } + + @Test + public void testNonEntityInheritanceBinding() { + @Include(rootLevel = false) + class SuperclassBinding { + @Id + private long id; + } + + class SubclassBinding extends SuperclassBinding { + } + + class SubsubclassBinding extends SubclassBinding { + } + + bindEntity(SuperclassBinding.class); + + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SubclassBinding.class)).entityClass); + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SuperclassBinding.class)).entityClass); + + assertEquals(ClassType.of(SuperclassBinding.class), lookupIncludeClass(ClassType.of(SuperclassBinding.class))); + assertEquals(ClassType.of(SuperclassBinding.class), lookupIncludeClass(ClassType.of(SubclassBinding.class))); + assertEquals(ClassType.of(SuperclassBinding.class), lookupIncludeClass(ClassType.of(SubsubclassBinding.class))); + } + + @Test + public void testNonEntityInheritanceBindingOverride() { + @Include(rootLevel = false) + class SuperclassBinding { + @Id + private long id; + } + + class SubclassBinding extends SuperclassBinding { + } + + @Include(rootLevel = false) + class SubsubclassBinding extends SubclassBinding { + @Id + private long id; + } + + bindEntity(SuperclassBinding.class); + bindEntity(SubsubclassBinding.class); + + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SubclassBinding.class)).entityClass); + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SuperclassBinding.class)).entityClass); + assertEquals(ClassType.of(SubsubclassBinding.class), getEntityBinding(ClassType.of(SubsubclassBinding.class)).entityClass); + + assertEquals(ClassType.of(SuperclassBinding.class), lookupIncludeClass(ClassType.of(SuperclassBinding.class))); + assertEquals(ClassType.of(SuperclassBinding.class), lookupIncludeClass(ClassType.of(SubclassBinding.class))); + assertEquals(ClassType.of(SubsubclassBinding.class), lookupIncludeClass(ClassType.of(SubsubclassBinding.class))); + } + + @Test + public void testNonEntityInheritanceBindingExclusion() { + @Include(rootLevel = false) + class SuperclassBinding { + @Id + private long id; + } + + class SubclassBinding extends SuperclassBinding { + } + + @Exclude + class SubsubclassBinding extends SubclassBinding { + } + + bindEntity(SuperclassBinding.class); + bindEntity(SubsubclassBinding.class); + + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SubclassBinding.class)).entityClass); + assertEquals(ClassType.of(SuperclassBinding.class), getEntityBinding(ClassType.of(SuperclassBinding.class)).entityClass); + assertThrows(IllegalArgumentException.class, () -> + getEntityBinding(ClassType.of(SubsubclassBinding.class)) + ); + + assertEquals(ClassType.of(SuperclassBinding.class), lookupIncludeClass(ClassType.of(SuperclassBinding.class))); + assertEquals(ClassType.of(SuperclassBinding.class), lookupIncludeClass(ClassType.of(SubclassBinding.class))); + assertNull(lookupIncludeClass(ClassType.of(SubsubclassBinding.class))); + } + + @Test + public void testGetFirstAnnotation() { + @Exclude + class Foo { + } + + @Include(rootLevel = false) + class Bar extends Foo { + + } + + class Baz extends Bar { + + } + + Annotation first = getFirstAnnotation(ClassType.of(Baz.class), Arrays.asList(Exclude.class, Include.class)); + assertTrue(first instanceof Include); + } + + @Test + public void testGetFirstAnnotationConflict() { + @Exclude + @Include(rootLevel = false) + class Foo { + } + + Annotation first = getFirstAnnotation(ClassType.of(Foo.class), Arrays.asList(Exclude.class, Include.class)); + assertTrue(first instanceof Exclude); + } + + @Test + public void testAnnotationNoSuchMethod() { + bindEntity(Book.class); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> getMethodAnnotation(ClassType.of(Book.class), "NoMethod", FilterExpressionPath.class)); + assertTrue(e.getCause() instanceof NoSuchMethodException, e.toString()); + } + + @Test + public void testAnnotationFilterExpressionPath() { + bindEntity(Book.class); + FilterExpressionPath fe = + getMethodAnnotation(ClassType.of(Book.class), "getEditor", FilterExpressionPath.class); + assertEquals("publisher.editor", fe.value()); + } + + @Test + public void testBadLookupEntityClass() { + assertThrows(IllegalArgumentException.class, () -> lookupEntityClass(null)); + assertThrows(IllegalArgumentException.class, () -> lookupEntityClass(ClassType.of(Object.class))); + } + + @Test + public void testFieldIsInjected() { + EntityDictionary testDictionary = EntityDictionary.builder().build(); + + @Include(rootLevel = false) + class FieldInject { + @Inject + private String field; + } + + testDictionary.bindEntity(FieldInject.class); + + assertTrue(testDictionary.getEntityBinding(ClassType.of(FieldInject.class)).isInjected()); + } + + @Test + public void testInheritedFieldIsInjected() { + EntityDictionary testDictionary = EntityDictionary.builder().build(); + class BaseClass { + @Inject + private String field; + } + + @Include(rootLevel = false) + class SubClass extends BaseClass { + private String anotherField; + } + + testDictionary.bindEntity(SubClass.class); + + assertTrue(testDictionary.getEntityBinding(ClassType.of(SubClass.class)).isInjected()); + } + + @Test + public void testMethodIsInjected() { + EntityDictionary testDictionary = EntityDictionary.builder().build(); + + @Include(rootLevel = false) + class MethodInject { + @Inject + private void setField(String field) { + //NOOP + } + } + + testDictionary.bindEntity(MethodInject.class); + + assertTrue(testDictionary.getEntityBinding(ClassType.of(MethodInject.class)).isInjected()); + } + + @Test + public void testInhertedMethodIsInjected() { + EntityDictionary testDictionary = EntityDictionary.builder().build(); + class BaseClass { + @Inject + private void setField(String field) { + //NOOP + } + } + + @Include(rootLevel = false) + class SubClass extends BaseClass { + private String anotherField; + } + + testDictionary.bindEntity(SubClass.class); + + assertTrue(testDictionary.getEntityBinding(ClassType.of(SubClass.class)).isInjected()); + } + + @Test + public void testConstructorIsInjected() { + EntityDictionary testDictionary = EntityDictionary.builder().build(); + + @Include(rootLevel = false) + class ConstructorInject { + @Inject + public ConstructorInject(String field) { + //NOOP + } + } + + testDictionary.bindEntity(ConstructorInject.class); + + assertTrue(testDictionary.getEntityBinding(ClassType.of(ConstructorInject.class)).isInjected()); + } + + @Test + public void testFieldLookup() throws Exception { + Book book = new Book() { + @Override + public String toString() { + return "ProxyBook"; + } + }; + book.setId(1234L); + Author author = new Author(); + + initializeEntity(book); + + RequestScope scope = mock(RequestScope.class); + + assertEquals("Book", getSimpleName(ClassType.of(Book.class))); + assertEquals("getEditor", + findMethod(ClassType.of(Book.class), "getEditor").getName()); + assertEquals("setGenre", + findMethod(ClassType.of(Book.class), "setGenre", ClassType.of(String.class)).getName()); + + setValue(book, "genre", "Elide"); + assertEquals("Elide", getValue(book, "genre", scope)); + setValue(book, "authors", ImmutableSet.of(author)); + assertEquals(ImmutableSet.of(author), getValue(book, "authors", scope)); + + assertThrows(InvalidAttributeException.class, () -> setValue(book, "badfield", "Elide")); + assertEquals("1234", getId(book)); + assertTrue(isRoot(ClassType.of(Book.class))); + + assertEquals(ClassType.of(Book.class), lookupBoundClass(ClassType.of(Book.class))); + assertNull(lookupBoundClass(ClassType.of(String.class))); + // check proxy lookup + assertNotEquals(ClassType.of(Book.class), book.getClass()); + assertEquals(ClassType.of(Book.class), lookupBoundClass(ClassType.of(Book.class))); + + assertFalse(isComputed(ClassType.of(Book.class), "genre")); + assertTrue(isComputed(ClassType.of(Book.class), "editor")); + assertTrue(isComputed(ClassType.of(Editor.class), "fullName")); + assertFalse(isComputed(ClassType.of(Editor.class), "badfield")); + + assertEquals( + ImmutableSet.of("awards", "genre", "language", "title"), + getFieldsOfType(ClassType.of(Book.class), ClassType.of(String.class))); + + assertTrue(isRelation(ClassType.of(Book.class), "editor")); + assertTrue(isAttribute(ClassType.of(Book.class), "title")); + assertEquals( + Arrays.asList(ClassType.of(Book.class), ClassType.of(Author.class), + ClassType.of(Editor.class), ClassType.of(Publisher.class)), + walkEntityGraph(ImmutableSet.of(ClassType.of(Book.class)), x -> x)); + + assertTrue(hasBinding(ClassType.of(Book.class))); + assertFalse(hasBinding(ClassType.of(String.class))); + } + + @Test + public void testCoerce() throws Exception { + bindEntity(CoerceBean.class); + CoerceBean bean = new CoerceBean(); + + setValue(bean, "string", 1L); + setValue(bean, "list", ImmutableSet.of(true, false)); + setValue(bean, "map", ImmutableMap.of("one", "1", "two", "2")); + setValue(bean, "set", ImmutableList.of(3L, 4L)); + + assertEquals("1", bean.string); + assertEquals(Arrays.asList(true, false), bean.list); + assertEquals( + ImmutableMap.of("one", 1L, "two", 2L), + bean.map); + assertEquals(ImmutableSet.of(3.0, 4.0), bean.set); + } + + public static class TestCheck extends UserCheck { + + @Override + public boolean ok(com.yahoo.elide.core.security.User user) { + throw new IllegalStateException(); + } + } + + @Test + public void testCheckLookup() throws Exception { + assertEquals(Role.ALL.class, this.getCheck("user has all access")); + + assertEquals(TestCheck.class, this.getCheck("com.yahoo.elide.core.dictionary.EntityDictionaryTest$TestCheck")); + + assertThrows(IllegalArgumentException.class, () -> this.getCheck("UnknownClassName")); + + assertThrows(IllegalArgumentException.class, () -> this.getCheck(String.class.getName())); + } + + @Test + public void testAttributeOrRelationAnnotationExists() { + assertTrue(attributeOrRelationAnnotationExists(ClassType.of(Job.class), "jobId", Id.class)); + assertFalse(attributeOrRelationAnnotationExists(ClassType.of(Job.class), "title", OneToOne.class)); + } + + @Test + public void testIsValidField() { + assertTrue(isValidField(ClassType.of(Job.class), "title")); + assertFalse(isValidField(ClassType.of(Job.class), "foo")); + } + + @Test + public void testBindingHiddenAttribute() { + @Include + class Book { + @Id + long id; + + String notHidden; + + String hidden; + } + + bindEntity(Book.class, (field) -> field.getName().equals("hidden") ? true : false); + + assertFalse(isAttribute(ClassType.of(Book.class), "hidden")); + assertTrue(isAttribute(ClassType.of(Book.class), "notHidden")); + } + + @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(ClassType.of(BookV2.class))); + + models = getBoundClassesByVersion(NO_VERSION); + assertEquals(21, models.size()); + } + + @Test + public void testGetEntityClassByVersion() { + Type model = getEntityClass("book", NO_VERSION); + assertEquals(ClassType.of(Book.class), model); + + model = getEntityClass("book", "1.0"); + assertEquals(ClassType.of(BookV2.class), model); + } + + @Test + public void testGetModelVersion() { + assertEquals("1.0", getModelVersion(ClassType.of(BookV2.class))); + assertEquals(NO_VERSION, getModelVersion(ClassType.of(Book.class))); + } + + @Test + public void testIsComplexAttribute() { + //Test complex attribute + assertTrue(isComplexAttribute(ClassType.of(Author.class), "homeAddress")); + //Test nested complex attribute + assertTrue(isComplexAttribute(ClassType.of(Address.class), "geo")); + //Test another complex attribute. + assertTrue(isComplexAttribute(ClassType.of(Book.class), "price")); + //Test Java Type with no default constructor. + assertFalse(isComplexAttribute(ClassType.of(Price.class), "currency")); + //Test embedded Elide model + assertFalse(isComplexAttribute(ClassType.of(Price.class), "book")); + //Test String + assertFalse(isComplexAttribute(ClassType.of(Book.class), "title")); + //Test primitive + assertFalse(isComplexAttribute(ClassType.of(Book.class), "publishDate")); + //Test primitive wrapper + assertFalse(isComplexAttribute(ClassType.of(FieldAnnotations.class), "privateField")); + //Test collection + assertFalse(isComplexAttribute(ClassType.of(Book.class), "awards")); + //Test relationship + assertFalse(isComplexAttribute(ClassType.of(Book.class), "authors")); + //Test enum + assertFalse(isComplexAttribute(ClassType.of(Author.class), "authorType")); + //Test collection of complex type + assertFalse(isComplexAttribute(ClassType.of(Author.class), "vacationHomes")); + //Test map of objects + assertFalse(isComplexAttribute(ClassType.of(Author.class), "stuff")); + } + + @Test + public void testHasBinding() { + assertTrue(hasBinding(ClassType.of(FunWithPermissions.class))); + assertTrue(hasBinding(ClassType.of(Parent.class))); + assertTrue(hasBinding(ClassType.of(Child.class))); + assertTrue(hasBinding(ClassType.of(User.class))); + assertTrue(hasBinding(ClassType.of(Left.class))); + assertTrue(hasBinding(ClassType.of(Right.class))); + assertTrue(hasBinding(ClassType.of(StringId.class))); + assertTrue(hasBinding(ClassType.of(Friend.class))); + assertTrue(hasBinding(ClassType.of(FieldAnnotations.class))); + assertTrue(hasBinding(ClassType.of(Manager.class))); + assertTrue(hasBinding(ClassType.of(Employee.class))); + assertTrue(hasBinding(ClassType.of(Job.class))); + assertTrue(hasBinding(ClassType.of(NoId.class))); + assertTrue(hasBinding(ClassType.of(BookV2.class))); + assertTrue(hasBinding(ClassType.of(Book.class))); + assertTrue(hasBinding(ClassType.of((IncludedPackageLevel.class)))); + assertTrue(hasBinding(ClassType.of((IncludedSubPackage.class)))); + assertFalse(hasBinding(ClassType.of((ExcludedPackageLevel.class)))); + assertFalse(hasBinding(ClassType.of((ExcludedSubPackage.class)))); + assertFalse(hasBinding(ClassType.of((ExcludedBySuperClass.class)))); + + //Test bindings for complex attribute types + assertTrue(hasBinding(ClassType.of(Address.class))); + assertTrue(hasBinding(ClassType.of(GeoLocation.class))); + assertFalse(hasBinding(ClassType.of(String.class))); + assertFalse(hasBinding(ClassType.of(Author.AuthorType.class))); + assertFalse(hasBinding(ClassType.of(Boolean.class))); + assertFalse(hasBinding(ClassType.of(Date.class))); + assertFalse(hasBinding(ClassType.of(Map.class))); + assertFalse(hasBinding(ClassType.of(BigDecimal.class))); + } + + @Test + public void testEntityPrefix() { + assertEquals("example_includedPackageLevel", + getJsonAliasFor(ClassType.of(IncludedPackageLevel.class))); + } + + @Test + public void testEntityDescription() { + assertEquals("A book publisher", EntityDictionary.getEntityDescription(ClassType.of(Publisher.class))); + assertNull(EntityDictionary.getEntityDescription(ClassType.of(Book.class))); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/dictionary/TestDictionary.java b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/TestDictionary.java new file mode 100644 index 0000000000..5412ef8848 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/TestDictionary.java @@ -0,0 +1,79 @@ +/* + * 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.dictionary; + +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +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.Collections; +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, + Collections.emptyMap(), //role Checks + injector, + CoerceUtil::lookup, + Collections.emptySet(), + DefaultClassScanner.getInstance() + ); + } + + @Override + public Type lookupBoundClass(Type 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/dictionary/TestInjector.java b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/TestInjector.java new file mode 100644 index 0000000000..c52693b20d --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/TestInjector.java @@ -0,0 +1,31 @@ +/* + * 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.dictionary; + +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/exceptions/ErrorMapperTest.java b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java new file mode 100644 index 0000000000..81a8ac96f3 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java @@ -0,0 +1,154 @@ +/* + * Copyright 2021, 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 static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +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.ElideSettings; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.TransactionRegistry; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.TestDictionary; +import com.yahoo.elide.core.lifecycle.FieldTestModel; +import com.yahoo.elide.core.lifecycle.LegacyTestModel; +import com.yahoo.elide.core.lifecycle.PropertyTestModel; +import com.yahoo.elide.core.security.TestUser; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.type.ClassType; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +/** + * Tests the error mapping logic. + */ +public class ErrorMapperTest { + + private final String baseUrl = "http://localhost:8080/api/v1"; + private static final ErrorMapper MOCK_ERROR_MAPPER = mock(ErrorMapper.class); + private static final Exception EXPECTED_EXCEPTION = new IllegalStateException("EXPECTED_EXCEPTION"); + private static final CustomErrorException MAPPED_EXCEPTION = new CustomErrorException( + 422, + "MAPPED_EXCEPTION", + ErrorObjects.builder() + .addError() + .withCode("SOME_ERROR") + .build() + ); + private EntityDictionary dictionary; + + ErrorMapperTest() throws Exception { + dictionary = TestDictionary.getTestDictionary(); + dictionary.bindEntity(FieldTestModel.class); + dictionary.bindEntity(PropertyTestModel.class); + dictionary.bindEntity(LegacyTestModel.class); + } + + @AfterEach + private void afterEach() { + reset(MOCK_ERROR_MAPPER); + } + + @Test + public void testElideCreateNoErrorMapper() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, null); + + String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; + + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(eq(ClassType.of(FieldTestModel.class)), any())).thenReturn(mockModel); + doThrow(EXPECTED_EXCEPTION).when(tx).preCommit(any()); + + RuntimeException result = assertThrows(RuntimeException.class, () -> elide.post(baseUrl, "/testModel", body, null, NO_VERSION)); + assertEquals(EXPECTED_EXCEPTION, result.getCause()); + + verify(tx).close(); + } + + @Test + public void testElideCreateWithErrorMapperUnmapped() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_ERROR_MAPPER); + + String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; + + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(eq(ClassType.of(FieldTestModel.class)), any())).thenReturn(mockModel); + doThrow(EXPECTED_EXCEPTION).when(tx).preCommit(any()); + + RuntimeException result = assertThrows(RuntimeException.class, () -> elide.post(baseUrl, "/testModel", body, null, NO_VERSION)); + assertEquals(EXPECTED_EXCEPTION, result.getCause()); + + verify(tx).close(); + } + + @Test + public void testElideCreateWithErrorMapperMapped() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_ERROR_MAPPER); + + String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; + + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(eq(ClassType.of(FieldTestModel.class)), any())).thenReturn(mockModel); + doThrow(EXPECTED_EXCEPTION).when(tx).preCommit(any()); + when(MOCK_ERROR_MAPPER.map(EXPECTED_EXCEPTION)).thenReturn(MAPPED_EXCEPTION); + + ElideResponse response = elide.post(baseUrl, "/testModel", body, null, NO_VERSION); + assertEquals(422, response.getResponseCode()); + assertEquals( + "{\"errors\":[{\"code\":\"SOME_ERROR\"}]}", + response.getBody()); + + verify(tx).close(); + } + + private Elide getElide(DataStore dataStore, EntityDictionary dictionary, ErrorMapper errorMapper) { + ElideSettings settings = getElideSettings(dataStore, dictionary, errorMapper); + return new Elide(settings, new TransactionRegistry(), settings.getDictionary().getScanner(), false); + } + + private ElideSettings getElideSettings(DataStore dataStore, EntityDictionary dictionary, ErrorMapper errorMapper) { + return new ElideSettingsBuilder(dataStore) + .withEntityDictionary(dictionary) + .withErrorMapper(errorMapper) + .withVerboseErrors() + .build(); + } + + private RequestScope buildRequestScope(EntityDictionary dict, DataStoreTransaction tx) { + User user = new TestUser("1"); + + return new RequestScope(null, null, NO_VERSION, null, tx, user, null, null, UUID.randomUUID(), + getElideSettings(null, dict, MOCK_ERROR_MAPPER)); + } +} 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 new file mode 100644 index 0000000000..9d0b8cba51 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/HttpStatusExceptionTest.java @@ -0,0 +1,52 @@ +/* + * 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.exceptions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Test; + +import java.util.function.Supplier; + +public class HttpStatusExceptionTest { + + @Test + public void testGetEncodedResponse() { + String expected = "{\"errors\":[{\"detail\":\"test<script>encoding\"}]}"; + HttpStatusException exception = new HttpStatusException(500, "test\"}){edges{node{id}}}}" +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidAttributeException.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidAttributeException.json new file mode 100644 index 0000000000..bf2dd56f8f --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidAttributeException.json @@ -0,0 +1,8 @@ +[{ + "errors": [ + { + "detail": "Unknown attribute badAttr in parent", + "status": "404" + } + ] +}] diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidCollection.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidCollection.json new file mode 100644 index 0000000000..ed9761c6a3 --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidCollection.json @@ -0,0 +1,7 @@ +{ + "errors":[ + { + "detail" : "Unknown collection unknown" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidCollectionErrorObject.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidCollectionErrorObject.json new file mode 100644 index 0000000000..25d10427df --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidCollectionErrorObject.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail":"Unknown collection unknown" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidEntityBodyException.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidEntityBodyException.json new file mode 100644 index 0000000000..317bbe0c2b --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidEntityBodyException.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail" : "Bad Request Body'[{"op":"add","path":"/parent","value":{"id":"23","type":"parent"},"relationships":{"spouses":{}}}]'" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidEntityBodyException.req.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidEntityBodyException.req.json new file mode 100644 index 0000000000..0bede8e421 --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidEntityBodyException.req.json @@ -0,0 +1,13 @@ +[ + { + "op": "add", + "path": "/parent", + "value": { + "id": "23", + "type": "parent" + }, + "relationships": { + "spouses": {} + } + } +] diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidEntityBodyExceptionErrorObject.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidEntityBodyExceptionErrorObject.json new file mode 100644 index 0000000000..edfe2d345d --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidEntityBodyExceptionErrorObject.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail":"Bad Request Body'[{"op":"add","path":"/parent","value":{"id":"23","type":"parent"},"relationships":{"spouses":{}}}]'" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidObjectIdentifierException.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidObjectIdentifierException.json new file mode 100644 index 0000000000..56080988d4 --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidObjectIdentifierException.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail" : "Unknown identifier 100 for parent" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidObjectIdentifierExceptionErrorObject.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidObjectIdentifierExceptionErrorObject.json new file mode 100644 index 0000000000..6a919a51b0 --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidObjectIdentifierExceptionErrorObject.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail":"Unknown identifier 100 for parent" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidValueException.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidValueException.json new file mode 100644 index 0000000000..6327703261 --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidValueException.json @@ -0,0 +1,5 @@ +{ + "errors": [ + "InvalidValueException: Invalid value: a" + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidValueExceptionErrorObject.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidValueExceptionErrorObject.json new file mode 100644 index 0000000000..48da95ed1f --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidValueExceptionErrorObject.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail":"Invalid value: a" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidValueExceptionVerbose.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidValueExceptionVerbose.json new file mode 100644 index 0000000000..395060a6af --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/invalidValueExceptionVerbose.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail": "Invalid value: a\nError converting from 'String' to 'Long' For input string: "a"" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/jsonPatchExtensionException.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/jsonPatchExtensionException.json new file mode 100644 index 0000000000..5301fa3fb8 --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/jsonPatchExtensionException.json @@ -0,0 +1,8 @@ +[{ + "errors" : [ + { + "detail": "Bad Request Body'Could not parse patch extension value: {"id":"6","type":"parent","notARelationship":{}}'", + "status": "400" + } + ] +}] diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/jsonPatchExtensionException.req.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/jsonPatchExtensionException.req.json new file mode 100644 index 0000000000..1386a15bb8 --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/jsonPatchExtensionException.req.json @@ -0,0 +1,11 @@ +[ + { + "op": "add", + "path": "/-", + "value": { + "id": "6", + "type": "parent", + "notARelationship": {} + } + } +] diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/jsonPatchExtensionExceptionErrorObject.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/jsonPatchExtensionExceptionErrorObject.json new file mode 100644 index 0000000000..9904934d0d --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/jsonPatchExtensionExceptionErrorObject.json @@ -0,0 +1,8 @@ +[{ + "errors": [ + { + "detail": "Bad Request Body'Could not parse patch extension value: {"id":"6","type":"parent","notARelationship":{}}'", + "status": "400" + } + ] +}] diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/transactionException.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/transactionException.json new file mode 100644 index 0000000000..b29cab2df3 --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/transactionException.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail" : "Unexpected character ('"' (code 34)): was expecting comma to separate Object entries\n at [Source: (String)"{"data": {"type": "invoice" "id": 100}}"; line: 1, column: 30]" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/transactionExceptionErrorObject.json b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/transactionExceptionErrorObject.json new file mode 100644 index 0000000000..632b633cf6 --- /dev/null +++ b/elide-integration-tests/src/test/resources/EncodedErrorResponsesIT/transactionExceptionErrorObject.json @@ -0,0 +1,7 @@ +{ + "errors": [ + { + "detail":"Unexpected character ('"' (code 34)): was expecting comma to separate Object entries\n at [Source: (String)"{"data": {"type": "invoice" "id": 100}}"; line: 1, column: 30]" + } + ] +} diff --git a/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch1.json b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch1.json new file mode 100644 index 0000000000..6bcdfc4132 --- /dev/null +++ b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch1.json @@ -0,0 +1,99 @@ +[ + { + "op": "add", + "path": "/author", + "value": { + "id": "12345678-1234-1234-1234-1234567890ab", + "type": "author", + "attributes": { + "name": "Ernest Hemingway", + "homeAddress": "main" + }, + "relationships": { + "books": { + "data": [ + { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ac" + }, + { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ad" + } + ] + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ac", + "attributes": { + "title": "The Old Man and the Sea", + "genre": "Literary Fiction", + "language": "English", + "awards": ["National Book Award", "Booker Prize"], + "price" : { + "total" : 10.0, + "currency" : { + "isoCode" : "USD" + } + } + }, + "relationships": { + "publisher": { + "data": { + "type": "publisher", + "id": "12345678-1234-1234-1234-1234567890ae" + } + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ad", + "attributes": { + "title": "For Whom the Bell Tolls", + "genre": "Literary Fiction", + "language": "English", + "price" : { + "total" : 12.0, + "currency" : { + "isoCode" : "AED" + } + } + } + } + }, + { + "op": "add", + "path": "/book/12345678-1234-1234-1234-1234567890ac/publisher", + "value": { + "type": "publisher", + "id": "12345678-1234-1234-1234-1234567890ae", + "attributes": { + "name": "Default publisher", + "phoneNumbers": ["999-987-8394", "987-654-3210"] + } + } + }, + { + "op": "add", + "path": "/book/12345678-1234-1234-1234-1234567890ac/publisher/12345678-1234-1234-1234-1234567890ae/editor", + "value": { + "type": "editor", + "id": "12345678-1234-1234-1234-1234567890ba", + "attributes": { + "firstName": "John", + "lastName": "Doe" + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch2.json b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch2.json new file mode 100644 index 0000000000..cb3b8c1b7f --- /dev/null +++ b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch2.json @@ -0,0 +1,40 @@ +[ + { + "op": "add", + "path": "/author", + "value": { + "id": "12345678-1234-1234-1234-1234567890ab", + "type": "author", + "attributes": { + "name": "Thomas Harris" + }, + "relationships": { + "books": { + "data": [ + { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ac" + }, + { + "type": "book", + "id": "1" + } + ] + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ac", + "attributes": { + "title": "I'm OK - You're OK", + "genre": "Psychology & Counseling", + "language": "English" + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch3.json b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch3.json new file mode 100644 index 0000000000..52b6613376 --- /dev/null +++ b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch3.json @@ -0,0 +1,38 @@ +[ + { + "op": "add", + "path": "/author", + "value": { + "id": "12345679-1234-1234-1234-1234567890ab", + "type": "author", + "attributes": { + "name": "Orson Scott Card" + }, + "relationships": { + "books": { + "data": [ + { + "type": "book", + "id": "12345679-1234-1234-1234-1234567890ac" + } + ] + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345679-1234-1234-1234-1234567890ac", + "attributes": { + "title": "Enders Game", + "genre": "Science Fiction", + "language": "English", + "publishDate": 1454638927412, + "awards": ["Pulitzer Prize"] + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch4.json b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch4.json new file mode 100644 index 0000000000..b1622daaa3 --- /dev/null +++ b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch4.json @@ -0,0 +1,66 @@ +[ + { + "op": "add", + "path": "/author", + "value": { + "id": "12345680-1234-1234-1234-1234567890ab", + "type": "author", + "attributes": { + "name": "Isaac Asimov" + }, + "relationships": { + "books": { + "data": [ + { + "type": "book", + "id": "12345680-1234-1234-1234-1234567890ac" + }, + { + "type": "book", + "id": "12345680-1234-1234-1234-1234567890ad" + } + ] + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345680-1234-1234-1234-1234567890ac", + "attributes": { + "title": "Foundation", + "genre": "Science Fiction", + "language": "English", + "awards": ["BookBrowse Awards"] + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345680-1234-1234-1234-1234567890ad", + "attributes": { + "title": "The Roman Republic", + "genre": "History", + "language": "English", + "awards": [] + } + } + }, + { + "op": "add", + "path": "/book/12345680-1234-1234-1234-1234567890ad/chapters", + "value": { + "type": "chapter", + "id": "12345680-1234-1234-1234-1234567890ae", + "attributes": { + "title": "Viva la Roma!" + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch5.json b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch5.json new file mode 100644 index 0000000000..4a7d4f279f --- /dev/null +++ b/elide-integration-tests/src/test/resources/FilterIT/book_author_publisher_patch5.json @@ -0,0 +1,66 @@ +[ + { + "op": "add", + "path": "/author", + "value": { + "id": "12345681-1234-1234-1234-1234567890ab", + "type": "author", + "attributes": { + "name": "Null Ned" + }, + "relationships": { + "books": { + "data": [ + { + "type": "book", + "id": "12345681-1234-1234-1234-1234567890ac" + }, + { + "type": "book", + "id": "12345681-1234-1234-1234-1234567890ad" + } + ] + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345681-1234-1234-1234-1234567890ac", + "attributes": { + "title": "Life with Null Ned", + "language": "English", + "editorName": "Ed", + "awards": ["Pulitzer Prize", "Booker Prize"] + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345681-1234-1234-1234-1234567890ad", + "attributes": { + "title": "Life with Null Ned 2", + "genre": "Not Null", + "language": "English", + "editorName": "Eddy" + } + } + }, + { + "op": "add", + "path": "/book/12345681-1234-1234-1234-1234567890ad/chapters", + "value": { + "type": "chapter", + "id": "12345680-1234-1234-1234-1234567890ae", + "attributes": { + "title": "Mamma mia I wantz some pizza!" + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/META-INF/persistence.xml b/elide-integration-tests/src/test/resources/META-INF/persistence.xml new file mode 100644 index 0000000000..47aa6021c9 --- /dev/null +++ b/elide-integration-tests/src/test/resources/META-INF/persistence.xml @@ -0,0 +1,22 @@ + + + + + + false + + + + + + + + + diff --git a/elide-integration-tests/src/test/resources/ResourceIT/Issue608.req.json b/elide-integration-tests/src/test/resources/ResourceIT/Issue608.req.json new file mode 100644 index 0000000000..478dcd1f9b --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/Issue608.req.json @@ -0,0 +1,37 @@ +[ + { + "op": "add", + "path": "/-", + "value": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "Parent1" + } + } + }, + { + "op": "replace", + "path": "/12345678-1234-1234-1234-123456789ab1", + "value": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "Corrected" + }, + "relationships": { + "children": { + "data": [{"type": "child", "id": "12345678-1234-1234-1234-123456789ab2"}] + } + } + } + }, + { + "op": "add", + "path": "/12345678-1234-1234-1234-123456789ab1/children", + "value": { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/Issue608.resp.json b/elide-integration-tests/src/test/resources/ResourceIT/Issue608.resp.json new file mode 100644 index 0000000000..4973809057 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/Issue608.resp.json @@ -0,0 +1,49 @@ +[ + { + "data":{ + "type":"parent", + "id":"5", + "attributes":{ + "firstName":"Corrected" + }, + "relationships":{ + "children":{ + "data":[ + { + "type":"child", + "id":"6" + } + ] + }, + "spouses":{ + "data":[] + } + } + } + }, + { + "data":null + }, + { + "data":{ + "type":"child", + "id":"6", + "attributes":{ + "name":null + }, + "relationships":{ + "friends":{ + "data":[] + }, + "parents":{ + "data":[ + { + "type":"parent", + "id":"5" + } + ] + } + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/addRelationshipChild.json b/elide-integration-tests/src/test/resources/ResourceIT/addRelationshipChild.json deleted file mode 100644 index cb310a3fd4..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/addRelationshipChild.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "data":{ - "type":"child", - "id":"6", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"2" - }, - { - "type":"parent", - "id":"5" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/addRelationshipChild.req.json b/elide-integration-tests/src/test/resources/ResourceIT/addRelationshipChild.req.json deleted file mode 100644 index d47fcc52ab..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/addRelationshipChild.req.json +++ /dev/null @@ -1 +0,0 @@ -{"data":[{"type":"parent","id":"2"}]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/addRelationships.2.json b/elide-integration-tests/src/test/resources/ResourceIT/addRelationships.2.json index 4d300b7973..7bd00f3036 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/addRelationships.2.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/addRelationships.2.json @@ -1,9 +1,9 @@ { "data":{ "type":"parent", - "id":"10", + "id":"1", "attributes":{ - "firstName":"bart" + "firstName":null }, "relationships":{ "children":{ @@ -18,7 +18,7 @@ }, { "type":"child", - "id":"7" + "id":"1" } ] }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdLong.json b/elide-integration-tests/src/test/resources/ResourceIT/assignedIdLong.json deleted file mode 100644 index 87a0deb0e3..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdLong.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"assignedIdLong", - "id":1, - "attributes": { - "value": 22 - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdLong.req.json b/elide-integration-tests/src/test/resources/ResourceIT/assignedIdLong.req.json deleted file mode 100644 index 87a0deb0e3..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdLong.req.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"assignedIdLong", - "id":1, - "attributes": { - "value": 22 - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdString.json b/elide-integration-tests/src/test/resources/ResourceIT/assignedIdString.json deleted file mode 100644 index d12148c8fb..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdString.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"assignedIdString", - "id":"user1", - "attributes": { - "value": 22 - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdString.req.json b/elide-integration-tests/src/test/resources/ResourceIT/assignedIdString.req.json deleted file mode 100644 index d12148c8fb..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdString.req.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"assignedIdString", - "id":"user1", - "attributes": { - "value": 22 - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdWithoutId.req.json b/elide-integration-tests/src/test/resources/ResourceIT/assignedIdWithoutId.req.json deleted file mode 100644 index b2ce50eb21..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/assignedIdWithoutId.req.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "data":{ - "type":"assignedIdString", - "attributes": { - "value": 22 - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/checkJsonApiPatchWithError.json b/elide-integration-tests/src/test/resources/ResourceIT/checkJsonApiPatchWithError.json index 39a87bb24c..639d66be7e 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/checkJsonApiPatchWithError.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/checkJsonApiPatchWithError.json @@ -1,11 +1,17 @@ -{ - "errors":[ - { - "detail":"Subsequent operation failed." - }, - { - "detail":"Unknown attribute 'badAttr' in 'parent'", - "status":404 - } - ] -} +[ + { + "errors": [ + { + "detail": "Subsequent operation failed." + } + ] + }, + { + "errors": [ + { + "detail":"Unknown attribute badAttr in parent", + "status":"404" + } + ] + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createAndRemoveParent.json b/elide-integration-tests/src/test/resources/ResourceIT/createAndRemoveParent.json index 5a645f592e..469ae09d9f 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/createAndRemoveParent.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/createAndRemoveParent.json @@ -2,7 +2,7 @@ { "data":{ "type":"parent", - "id":"9", + "id":"5", "attributes":{ "firstName":"ron jon" }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createAndRemoveParent.req.json b/elide-integration-tests/src/test/resources/ResourceIT/createAndRemoveParent.req.json index 8519bd81ea..c3d5567f86 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/createAndRemoveParent.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/createAndRemoveParent.req.json @@ -15,7 +15,7 @@ "path":"/-", "value":{ "type":"parent", - "id":"7" + "id":"4" } } ] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createAnotherFilterExpressionCheckObj.json b/elide-integration-tests/src/test/resources/ResourceIT/createAnotherFilterExpressionCheckObj.json deleted file mode 100644 index df6ec5b478..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createAnotherFilterExpressionCheckObj.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "data":{ - "type":"anotherFilterExpressionCheckObj", - "id":"1", - "attributes":{ - "anotherName":"anotherObj1", - "createDate":"1999" - }, - "relationships": { - "linkToParent": { - "data": { - "type": "filterExpressionCheckObj", - "id": "1" - } - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.bad.req.json b/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.bad.req.json deleted file mode 100644 index 89f21d0ad1..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.bad.req.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "data":{ - "type":"createButNoUpdate", - "id":"1", - "attributes":{ - "textValue": "test", - "cannotModify": "This should fail this whole create" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.req.json b/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.req.json deleted file mode 100644 index b5e226e7d6..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.req.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"createButNoUpdate", - "id":"1", - "attributes":{ - "textValue": "test" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.resp.json b/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.resp.json deleted file mode 100644 index abd549d463..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.resp.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "data":{ - "type":"createButNoUpdate", - "id":"1", - "attributes":{ - "cannotModify": "unmodified", - "textValue": "test" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.update.req.json b/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.update.req.json deleted file mode 100644 index 579c9471c5..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createButNoUpdate.update.req.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"createButNoUpdate", - "id":"1", - "attributes":{ - "textValue": "new value" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createChild.json b/elide-integration-tests/src/test/resources/ResourceIT/createChild.json deleted file mode 100644 index f082e20c78..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createChild.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "data":{ - "type":"child", - "id":"required", - "relationships":{ - "parents":{ - "data":[ - { - "type":"parent", - "id":"1" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createChildNonRootable.json b/elide-integration-tests/src/test/resources/ResourceIT/createChildNonRootable.json deleted file mode 100644 index b5e7b8c237..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createChildNonRootable.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"required", - "attributes":{ - "firstName":"I should not be created :x" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createChildRelateExisting.json b/elide-integration-tests/src/test/resources/ResourceIT/createChildRelateExisting.json index c332c5d617..8d1b6392ed 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/createChildRelateExisting.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/createChildRelateExisting.json @@ -2,7 +2,7 @@ { "data":{ "type":"child", - "id":"8", + "id":"6", "attributes":{ "name":null }, @@ -14,7 +14,7 @@ "data":[ { "type":"parent", - "id":"7" + "id":"4" } ] } diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createChildRelateExisting.req.json b/elide-integration-tests/src/test/resources/ResourceIT/createChildRelateExisting.req.json index 2aecd67e13..3f1337cfbe 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/createChildRelateExisting.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/createChildRelateExisting.req.json @@ -1,7 +1,7 @@ [ { "op":"add", - "path":"/parent/7/children", + "path":"/parent/4/children", "value":{ "type":"child", "id":"12345678-1234-1234-1234-123456789ab1", @@ -10,7 +10,7 @@ "data":[ { "type":"parent", - "id":"7" + "id":"4" } ] } diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createDependentPatchExt.json b/elide-integration-tests/src/test/resources/ResourceIT/createDependentPatchExt.json index 6e521bf767..aed8bba51c 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/createDependentPatchExt.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/createDependentPatchExt.json @@ -2,7 +2,7 @@ { "data":{ "type":"parent", - "id":"7", + "id":"5", "attributes":{ "firstName":"test parent" }, @@ -11,7 +11,7 @@ "data":[ { "type":"child", - "id":"7" + "id":"6" } ] }, @@ -24,7 +24,7 @@ { "data":{ "type":"child", - "id":"7", + "id":"6", "attributes":{ "name":null }, @@ -36,7 +36,7 @@ "data":[ { "type":"parent", - "id":"7" + "id":"5" } ] } diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createFilterExpressionCheckObj.1.json b/elide-integration-tests/src/test/resources/ResourceIT/createFilterExpressionCheckObj.1.json deleted file mode 100644 index 699ef374d6..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createFilterExpressionCheckObj.1.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"filterExpressionCheckObj", - "id":"1", - "attributes":{ - "name":"obj1" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createFilterExpressionCheckObj.2.json b/elide-integration-tests/src/test/resources/ResourceIT/createFilterExpressionCheckObj.2.json deleted file mode 100644 index 4343b9fa5a..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createFilterExpressionCheckObj.2.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"filterExpressionCheckObj", - "id":"2", - "attributes":{ - "name":"obj2" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createFilterExpressionCheckObj.3.json b/elide-integration-tests/src/test/resources/ResourceIT/createFilterExpressionCheckObj.3.json deleted file mode 100644 index fc12fe7668..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createFilterExpressionCheckObj.3.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"filterExpressionCheckObj", - "id":"3", - "attributes":{ - "name":"obj3" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createOneToOneNonRoot.json b/elide-integration-tests/src/test/resources/ResourceIT/createOneToOneNonRoot.json deleted file mode 100644 index 970ce5c02d..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createOneToOneNonRoot.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"OneToOneNonRoot", - "id":"1", - "attributes":{ - "test": "Other object" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createOneToOneRoot.json b/elide-integration-tests/src/test/resources/ResourceIT/createOneToOneRoot.json deleted file mode 100644 index 4da6bdb629..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createOneToOneRoot.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"OneToOneRoot", - "id":"1", - "attributes":{ - "name": "test123" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createParentBadUri.json b/elide-integration-tests/src/test/resources/ResourceIT/createParentBadUri.json deleted file mode 100644 index b5e7b8c237..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createParentBadUri.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"required", - "attributes":{ - "firstName":"I should not be created :x" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createParentNoRels.json b/elide-integration-tests/src/test/resources/ResourceIT/createParentNoRels.json deleted file mode 100644 index 03d45070f5..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createParentNoRels.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"5", - "attributes":{ - "firstName":"I'm new here" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createParentNoRels.req.json b/elide-integration-tests/src/test/resources/ResourceIT/createParentNoRels.req.json deleted file mode 100644 index a2d1a4d035..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createParentNoRels.req.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"required", - "attributes":{ - "firstName":"I'm new here" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createParentWithRels.json b/elide-integration-tests/src/test/resources/ResourceIT/createParentWithRels.json deleted file mode 100644 index 356b345ae4..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createParentWithRels.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"6", - "attributes":{ - "firstName":"omg. I have kidz." - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"2" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createParentWithRels.req.json b/elide-integration-tests/src/test/resources/ResourceIT/createParentWithRels.req.json deleted file mode 100644 index 5c6176b279..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createParentWithRels.req.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"required", - "attributes":{ - "firstName":"omg. I have kidz." - }, - "relationships":{ - "children":{ - "data":[ - { - "type":"child", - "id":"2" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createParentWithoutId.json b/elide-integration-tests/src/test/resources/ResourceIT/createParentWithoutId.json deleted file mode 100644 index 0a87752648..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createParentWithoutId.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":12, - "attributes":{ - "firstName":"omg. I have kidz." - }, - "relationships":{ - "children":{ - "data":[ - { - "type":"child", - "id":"2" - } - ] - }, - "spouses": { - "data": [] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createParentWithoutId.req.json b/elide-integration-tests/src/test/resources/ResourceIT/createParentWithoutId.req.json deleted file mode 100644 index 9d741e71c1..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createParentWithoutId.req.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "data":{ - "type":"parent", - "attributes":{ - "firstName":"omg. I have kidz." - }, - "relationships":{ - "children":{ - "data":[ - { - "type":"child", - "id":"2" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createYetAnotherPermissionRead.json b/elide-integration-tests/src/test/resources/ResourceIT/createYetAnotherPermissionRead.json deleted file mode 100644 index 8a7d8f56f2..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createYetAnotherPermissionRead.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data": [{ - "type": "yetAnotherPermission", - "id": "1", - "attributes": { - "youShouldBeAbleToRead": "this!" - } - }] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/createYetAnotherPermissionRead.req.json b/elide-integration-tests/src/test/resources/ResourceIT/createYetAnotherPermissionRead.req.json deleted file mode 100644 index 1363900d07..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/createYetAnotherPermissionRead.req.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data": { - "type": "yetAnotherPermission", - "id": "1", - "attributes": { - "youShouldBeAbleToRead": "this!" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/elideBypassSecurity.json b/elide-integration-tests/src/test/resources/ResourceIT/elideBypassSecurity.json deleted file mode 100644 index e9bd45a977..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/elideBypassSecurity.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "data":{ - "type":"child", - "id":"1", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "noReadAccess":{ - "data":null - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"1" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/failChild.json b/elide-integration-tests/src/test/resources/ResourceIT/failChild.json deleted file mode 100644 index af5a33b831..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/failChild.json +++ /dev/null @@ -1 +0,0 @@ -{"errors":["InvalidCollectionException: Unknown collection 'unknown'"]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/failFieldRequest.json b/elide-integration-tests/src/test/resources/ResourceIT/failFieldRequest.json deleted file mode 100644 index 795a29edb0..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/failFieldRequest.json +++ /dev/null @@ -1 +0,0 @@ -{"errors":["InvalidCollectionException: Unknown collection 'id'"]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/failPatchIdLong.req.json b/elide-integration-tests/src/test/resources/ResourceIT/failPatchIdLong.req.json deleted file mode 100644 index 26b210f0ce..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/failPatchIdLong.req.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"assignedIdLong", - "id":2, - "attributes": { - "value": 22 - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/failPatchIdString.req.json b/elide-integration-tests/src/test/resources/ResourceIT/failPatchIdString.req.json deleted file mode 100644 index 63d478844c..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/failPatchIdString.req.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"assignedIdString", - "id":"user2", - "attributes": { - "value": 22 - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/failRootCollection.json b/elide-integration-tests/src/test/resources/ResourceIT/failRootCollection.json deleted file mode 100644 index af5a33b831..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/failRootCollection.json +++ /dev/null @@ -1 +0,0 @@ -{"errors":["InvalidCollectionException: Unknown collection 'unknown'"]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/failRootCollectionId.json b/elide-integration-tests/src/test/resources/ResourceIT/failRootCollectionId.json deleted file mode 100644 index d2da9f59f1..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/failRootCollectionId.json +++ /dev/null @@ -1 +0,0 @@ -{"errors":["InvalidObjectIdentifierException: Unknown identifier '6789' for Parent"]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/invalidPatchMissingId.req.json b/elide-integration-tests/src/test/resources/ResourceIT/invalidPatchMissingId.req.json new file mode 100644 index 0000000000..79554f04cb --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/invalidPatchMissingId.req.json @@ -0,0 +1,27 @@ +[ + { + "op":"add", + "path":"/book", + "value":{ + "type":"book", + "relationships":{ + "authors":{ + "data":[ + { + "type":"author", + "id":"authorId" + } + ] + } + } + } + }, + { + "op":"add", + "path":"/author", + "value":{ + "id":"authorId", + "type":"author" + } + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/invalidPatchMissingPath.req.json b/elide-integration-tests/src/test/resources/ResourceIT/invalidPatchMissingPath.req.json new file mode 100644 index 0000000000..0827639d23 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/invalidPatchMissingPath.req.json @@ -0,0 +1,27 @@ +[ + { + "op":"add", + "value":{ + "type":"book", + "id":"bookId", + "relationships":{ + "authors":{ + "data":[ + { + "type":"author", + "id":"authorId" + } + ] + } + } + } + }, + { + "op":"add", + "path":"/author", + "value":{ + "id":"authorId", + "type":"author" + } + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/nestedPatchCreate.resp.json b/elide-integration-tests/src/test/resources/ResourceIT/nestedPatchCreate.resp.json index 1808ec75ed..50f659570f 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/nestedPatchCreate.resp.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/nestedPatchCreate.resp.json @@ -2,7 +2,7 @@ { "data": { "type": "parent", - "id": "13", + "id": "5", "attributes": { "firstName": "Parent1" }, @@ -11,7 +11,7 @@ "data": [ { "type": "child", - "id": "10" + "id": "6" } ] }, @@ -24,7 +24,7 @@ { "data": { "type": "child", - "id": "10", + "id": "6", "attributes": { "name": null }, @@ -36,11 +36,11 @@ "data": [ { "type": "parent", - "id": "13" + "id": "5" }, { "type": "parent", - "id": "14" + "id": "6" } ] } @@ -50,7 +50,7 @@ { "data": { "type": "parent", - "id": "14", + "id": "6", "attributes": { "firstName": "Parent2" }, @@ -59,7 +59,7 @@ "data": [ { "type": "child", - "id": "10" + "id": "6" } ] }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/noShareBiDirectional.req.json b/elide-integration-tests/src/test/resources/ResourceIT/noShareBiDirectional.req.json deleted file mode 100644 index c6d5e9026c..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/noShareBiDirectional.req.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "data":{ - "type":"noShareBid", - "id":"2", - "relationships":{ - "other":{ - "data": - { - "type":"noShareBid", - "id":"1" - } - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/oneToOneNonRootCreatedRelationship.json b/elide-integration-tests/src/test/resources/ResourceIT/oneToOneNonRootCreatedRelationship.json deleted file mode 100644 index e208cb9c62..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/oneToOneNonRootCreatedRelationship.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "data": { - "type": "oneToOneNonRoot", - "id": "1", - "attributes": { - "test": "Other object" - }, - "relationships": { - "root": { - "data": { - "type": "oneToOneRoot", - "id": "1" - } - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/oneToOneNonRootUpdatedRelationship.json b/elide-integration-tests/src/test/resources/ResourceIT/oneToOneNonRootUpdatedRelationship.json deleted file mode 100644 index 69795c8a7c..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/oneToOneNonRootUpdatedRelationship.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "data": { - "type": "oneToOneNonRoot", - "id": "2", - "attributes": { - "test": "Updated object" - }, - "relationships": { - "root": { - "data": { - "type": "oneToOneRoot", - "id": "1" - } - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/oneToOneRootCreatedRelationship.json b/elide-integration-tests/src/test/resources/ResourceIT/oneToOneRootCreatedRelationship.json deleted file mode 100644 index 4341992eb5..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/oneToOneRootCreatedRelationship.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "data": { - "type": "oneToOneRoot", - "id": "1", - "attributes": { - "name": "test123" - }, - "relationships": { - "otherObject": { - "data": { - "type": "oneToOneNonRoot", - "id": "1" - } - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/oneToOneRootUpdatedRelationship.json b/elide-integration-tests/src/test/resources/ResourceIT/oneToOneRootUpdatedRelationship.json deleted file mode 100644 index 6c072dedcd..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/oneToOneRootUpdatedRelationship.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "data": { - "type": "oneToOneRoot", - "id": "1", - "attributes": { - "name": "test123" - }, - "relationships": { - "otherObject": { - "data": { - "type": "oneToOneNonRoot", - "id": "2" - } - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/parseFailure.json b/elide-integration-tests/src/test/resources/ResourceIT/parseFailure.json deleted file mode 100644 index 82fde9c71a..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/parseFailure.json +++ /dev/null @@ -1 +0,0 @@ -{"errors":["InvalidURLException: token recognition error at: '|'"]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/patchExtAddUpdate.json b/elide-integration-tests/src/test/resources/ResourceIT/patchExtAddUpdate.json index f0d7024eee..24f6ed716f 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/patchExtAddUpdate.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/patchExtAddUpdate.json @@ -2,7 +2,7 @@ { "data":{ "type":"parent", - "id":"11", + "id":"5", "attributes":{ "firstName":null }, @@ -24,7 +24,7 @@ { "data":{ "type":"child", - "id":"9", + "id":"6", "attributes":{ "name":null }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadDelete.json b/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadDelete.json index 2f50b6efc0..3f7d8ca941 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadDelete.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadDelete.json @@ -1 +1 @@ -{"errors":[{"detail":"Unknown identifier '1' for Parent","status":404}]} +[{"errors":[{"detail":"Unknown identifier 10 for parent","status":"404"}]}] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadDelete.req.json b/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadDelete.req.json index db749f2140..aeae3632f2 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadDelete.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadDelete.req.json @@ -1,6 +1,6 @@ [ { "op": "remove", - "path": "parent/1" + "path": "parent/10" } ] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadId.json b/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadId.json index a567fd37ca..3001797991 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadId.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadId.json @@ -1 +1,4 @@ -{"errors":[{"detail":"Subsequent operation failed."},{"detail":"Subsequent operation failed."},{"detail":"Unknown identifier '99999' for Child","status":404},{"detail":"Operation not executed. Terminated by earlier failure."}]} +[ + {"errors":[{"detail":"Subsequent operation failed."}]}, + {"errors":[{"detail":"Unknown identifier 99999 for child","status":"404"}]} +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadId.req.json b/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadId.req.json index 887b1a8e0f..ee1640fe8a 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadId.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadId.req.json @@ -24,14 +24,6 @@ } } }, - { - "op": "remove", - "path": "parent", - "value": { - "type": "parent", - "id": "3" - } - }, { "op": "add", "path": "parent/5d57b6ee-f538-4e49-8f35-edc00f6272d2/children", @@ -58,13 +50,5 @@ } } } - }, - { - "op": "remove", - "path": "parent", - "value": { - "type": "parent", - "id": "4" - } } ] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadValue.json b/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadValue.json deleted file mode 100644 index 865d213e8c..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/patchExtBadValue.json +++ /dev/null @@ -1 +0,0 @@ -{"errors":["Duplicate entry 'duplicate' for key 'name'"]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/removeObject.1.json b/elide-integration-tests/src/test/resources/ResourceIT/removeObject.1.json index 9d04333c11..5acf20dd16 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/removeObject.1.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/removeObject.1.json @@ -2,7 +2,7 @@ { "data":{ "type":"parent", - "id":"8", + "id":"5", "attributes":{ "firstName":"Im going to be immediately deleted" }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/removeObject.2.req.json b/elide-integration-tests/src/test/resources/ResourceIT/removeObject.2.req.json index 2802f8e1ae..1d812ce843 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/removeObject.2.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/removeObject.2.req.json @@ -4,7 +4,7 @@ "path":"/-", "value":{ "type":"parent", - "id":"8" + "id":"5" } } ] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/removeObject.direct.json b/elide-integration-tests/src/test/resources/ResourceIT/removeObject.direct.json index ef6926a6e3..3edba7190f 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/removeObject.direct.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/removeObject.direct.json @@ -1,7 +1,7 @@ { "data":{ "type":"parent", - "id":"8", + "id":"5", "attributes":{ "firstName":"Im going to be immediately deleted" }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/removeRelationshipChild.json b/elide-integration-tests/src/test/resources/ResourceIT/removeRelationshipChild.json deleted file mode 100644 index 9316c35d77..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/removeRelationshipChild.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "data":{ - "type":"child", - "id":"6", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"5" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/removeRelationshipChild.req.json b/elide-integration-tests/src/test/resources/ResourceIT/removeRelationshipChild.req.json deleted file mode 100644 index d47fcc52ab..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/removeRelationshipChild.req.json +++ /dev/null @@ -1 +0,0 @@ -{"data":[{"type":"parent","id":"2"}]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/removeSingleRelationship.2.json b/elide-integration-tests/src/test/resources/ResourceIT/removeSingleRelationship.2.json index f7862ec985..86e89968ea 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/removeSingleRelationship.2.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/removeSingleRelationship.2.json @@ -1,9 +1,9 @@ { "data":{ "type":"parent", - "id":"10", + "id":"2", "attributes":{ - "firstName":"bart" + "firstName":"John" }, "relationships":{ "spouses":{ @@ -13,7 +13,7 @@ "data":[ { "type":"child", - "id":"7" + "id":"3" } ] } diff --git a/elide-integration-tests/src/test/resources/ResourceIT/removeSingleRelationship.req.json b/elide-integration-tests/src/test/resources/ResourceIT/removeSingleRelationship.req.json index fd64249a3f..e070be1d27 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/removeSingleRelationship.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/removeSingleRelationship.req.json @@ -5,7 +5,7 @@ "value":[ { "type":"child", - "id":"5" + "id":"2" } ] } diff --git a/elide-integration-tests/src/test/resources/ResourceIT/replaceAttributesAndRelationship.2.json b/elide-integration-tests/src/test/resources/ResourceIT/replaceAttributesAndRelationship.2.json index c33ae3ca6e..edd32b6653 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/replaceAttributesAndRelationship.2.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/replaceAttributesAndRelationship.2.json @@ -1,7 +1,7 @@ { "data":{ "type":"parent", - "id":"7", + "id":"1", "attributes":{ "firstName":"I've been modified =-o" }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/replaceAttributesAndRelationship.req.json b/elide-integration-tests/src/test/resources/ResourceIT/replaceAttributesAndRelationship.req.json index d74f26a0cb..5aa0430f7b 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/replaceAttributesAndRelationship.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/replaceAttributesAndRelationship.req.json @@ -1,9 +1,9 @@ [ { "op":"replace", - "path":"/7", + "path":"/1", "value":{ - "id":"7", + "id":"1", "type":"parent", "attributes":{ "firstName":"I've been modified =-o" diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.2.json b/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.2.json deleted file mode 100644 index 08504c47a0..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.2.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "data":{ - "type":"fun", - "id":"1", - "attributes":{ - "field6":null, - "field8":null, - "field3":null, - "field2":null, - "field5":null, - "field4":null - }, - "relationships":{ - "relation3":{ - "data":null - }, - "relation2":{ - "data":[] - }, - "relation1":{ - "data":[] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.2.req.json b/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.2.req.json deleted file mode 100644 index eb017cd130..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.2.req.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "data":{ - "type":"fun", - "id":"1", - "relationships":{ - "relation3":{ - "data":null - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.json b/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.json deleted file mode 100644 index 68fe666de1..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "data":{ - "type":"fun", - "id":"1", - "attributes":{ - "field6":null, - "field8":null, - "field3":null, - "field2":null, - "field5":null, - "field4":null - }, - "relationships":{ - "relation3":{ - "data":{ - "type":"child", - "id":"2" - } - }, - "relation2":{ - "data":[] - }, - "relation1":{ - "data":[] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.req.json b/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.req.json deleted file mode 100644 index 14d2b12cc1..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testAddAndRemoveOneToOneRelationship.req.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "data":{ - "type":"fun", - "id":"1", - "relationships":{ - "relation3":{ - "data":{ - "type":"child", - "id":"2" - } - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testAddRoot.1.json b/elide-integration-tests/src/test/resources/ResourceIT/testAddRoot.1.json index d1c30b3ee0..9368657774 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/testAddRoot.1.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/testAddRoot.1.json @@ -2,7 +2,7 @@ { "data":{ "type":"parent", - "id":"10", + "id":"5", "attributes":{ "firstName":"bart" }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testAddRoot.2.json b/elide-integration-tests/src/test/resources/ResourceIT/testAddRoot.2.json index 3287c63029..a22e98dc94 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/testAddRoot.2.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/testAddRoot.2.json @@ -1,7 +1,7 @@ { "data":{ "type":"parent", - "id":"10", + "id":"5", "attributes":{ "firstName":"bart" }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testChild.json b/elide-integration-tests/src/test/resources/ResourceIT/testChild.json deleted file mode 100644 index 9d4f5ebfc8..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testChild.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "data":{ - "type":"child", - "id":"1", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"1" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testComputedAttribute.json b/elide-integration-tests/src/test/resources/ResourceIT/testComputedAttribute.json deleted file mode 100644 index 67354bcda1..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testComputedAttribute.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "data":{ - "type":"user", - "id":"1", - "attributes":{ - "password":"", - "reversedPassword":"dog" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testComputedAttribute.req.json b/elide-integration-tests/src/test/resources/ResourceIT/testComputedAttribute.req.json deleted file mode 100644 index 2fbfea3e18..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testComputedAttribute.req.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"user", - "id":"1", - "attributes":{ - "password":"god" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testFilterIdCollection.json b/elide-integration-tests/src/test/resources/ResourceIT/testFilterIdCollection.json deleted file mode 100644 index 2b35b3a4e5..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testFilterIdCollection.json +++ /dev/null @@ -1,189 +0,0 @@ -{ - "data": [ - { - "type": "parent", - "id": "2", - "attributes": { - "firstName": "syzygy" - }, - "relationships": { - "children": { - "data": [] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "3", - "attributes": { - "firstName": "Link" - }, - "relationships": { - "children": { - "data": [ - { - "type": "child", - "id": "4" - } - ] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "4", - "attributes": { - "firstName": "Unknown" - }, - "relationships": { - "children": { - "data": [ - { - "type": "child", - "id": "4" - } - ] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "5", - "attributes": { - "firstName": "I'm new here" - }, - "relationships": { - "children": { - "data": [] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "6", - "attributes": { - "firstName": "omg. I have kidz." - }, - "relationships": { - "children": { - "data": [] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "9", - "attributes": { - "firstName": "ron jon" - }, - "relationships": { - "children": { - "data": [] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "10", - "attributes": { - "firstName": "bart" - }, - "relationships": { - "children": { - "data": [ - { - "type": "child", - "id": "4" - } - ] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "11", - "attributes": { - "firstName": null - }, - "relationships": { - "children": { - "data": [] - }, - "spouses": { - "data": [ - { - "type": "parent", - "id": "2" - } - ] - } - } - }, - { - "type": "parent", - "id": "12", - "attributes": { - "firstName": "omg. I have kidz." - }, - "relationships": { - "children": { - "data": [] - }, - "spouses": { - "data": [] - } - } - } - ], - "included": [ - { - "type": "child", - "id": "4", - "attributes": { - "name": null - }, - "relationships": { - "friends": { - "data": [] - }, - "parents": { - "data": [ - { - "type": "parent", - "id": "3" - }, - { - "type": "parent", - "id": "4" - }, - { - "type": "parent", - "id": "10" - } - ] - } - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testFilterIdIncluded.json b/elide-integration-tests/src/test/resources/ResourceIT/testFilterIdIncluded.json deleted file mode 100644 index 8c4861d846..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testFilterIdIncluded.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "data": { - "type": "parent", - "id": "10", - "attributes": { - "firstName": "bart" - }, - "relationships": { - "children": { - "data": [ - { - "type": "child", - "id": "4" - }, - { - "type": "child", - "id": "5" - } - ] - }, - "spouses": { - "data": [] - } - } - }, - "included": [ - { - "type": "child", - "id": "4", - "attributes": { - "name": null - }, - "relationships": { - "friends": { - "data": [] - }, - "parents": { - "data": [ - { - "type": "parent", - "id": "3" - }, - { - "type": "parent", - "id": "4" - }, - { - "type": "parent", - "id": "10" - } - ] - } - } - }, - { - "type": "child", - "id": "5", - "attributes": { - "name": null - }, - "relationships": { - "friends": { - "data": [] - }, - "parents": { - "data": [ - { - "type": "parent", - "id": "3" - }, - { - "type": "parent", - "id": "4" - }, - { - "type": "parent", - "id": "10" - } - ] - } - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testFilterIdRels.json b/elide-integration-tests/src/test/resources/ResourceIT/testFilterIdRels.json deleted file mode 100644 index b0a45388f2..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testFilterIdRels.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "data": [ - { - "type": "child", - "id": "4" - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testFiltered.json b/elide-integration-tests/src/test/resources/ResourceIT/testFiltered.json deleted file mode 100644 index 2ac43edd3e..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testFiltered.json +++ /dev/null @@ -1 +0,0 @@ -{"data":[{"type":"filtered","id":"1"},{"type":"filtered","id":"3"}]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testGetMultipleIncludeOnCollection.json b/elide-integration-tests/src/test/resources/ResourceIT/testGetMultipleIncludeOnCollection.json deleted file mode 100644 index 199e0bc7b7..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testGetMultipleIncludeOnCollection.json +++ /dev/null @@ -1,266 +0,0 @@ -{ - "data":[ - { - "type":"parent", - "id":"1", - "attributes":{ - "firstName":null - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"6" - }, - { - "type":"child", - "id":"1" - } - ] - } - } - }, - { - "type":"parent", - "id":"6", - "attributes":{ - "firstName":"omg. I have kidz." - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"2" - } - ] - } - } - }, - { - "type":"parent", - "id":"2", - "attributes":{ - "firstName":"syzygy" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"3" - }, - { - "type":"child", - "id":"2" - } - ] - } - } - }, - { - "type":"parent", - "id":"3", - "attributes":{ - "firstName":"Link" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"5" - }, - { - "type":"child", - "id":"4" - } - ] - } - } - }, - { - "type":"parent", - "id":"4", - "attributes":{ - "firstName":"Unknown" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[] - } - } - }, - { - "type":"parent", - "id":"5", - "attributes":{ - "firstName":"I'm new here" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"6" - } - ] - } - } - } - ], - "included":[ - { - "type":"child", - "id":"6", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"1" - }, - { - "type":"parent", - "id":"5" - } - ] - } - } - }, - { - "type":"child", - "id":"1", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"1" - } - ] - } - } - }, - { - "type":"child", - "id":"2", - "attributes":{ - "name":"Child-ID2" - }, - "relationships":{ - "friends":{ - "data":[ - { - "type":"child", - "id":"3" - } - ] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"6" - }, - { - "type":"parent", - "id":"2" - } - ] - } - } - }, - { - "type":"child", - "id":"3", - "attributes":{ - "name":"Child-ID3" - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"2" - } - ] - } - } - }, - { - "type":"child", - "id":"5", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"3" - } - ] - } - } - }, - { - "type":"child", - "id":"4", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"3" - } - ] - } - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testGetNestedSingleInclude.json b/elide-integration-tests/src/test/resources/ResourceIT/testGetNestedSingleInclude.json deleted file mode 100644 index 5cc758e0ed..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testGetNestedSingleInclude.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"2", - "attributes":{ - "firstName":"syzygy" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"3" - }, - { - "type":"child", - "id":"2" - } - ] - } - } - }, - "included":[ - { - "type":"child", - "id":"3", - "attributes":{ - "name":"Child-ID3" - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"2" - } - ] - } - } - }, - { - "type":"child", - "id":"2", - "attributes":{ - "name":"Child-ID2" - }, - "relationships":{ - "friends":{ - "data":[ - { - "type":"child", - "id":"3" - } - ] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"6" - }, - { - "type":"parent", - "id":"2" - } - ] - } - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testGetRelEmptyColl.json b/elide-integration-tests/src/test/resources/ResourceIT/testGetRelEmptyColl.json deleted file mode 100644 index f93ccdf4b7..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testGetRelEmptyColl.json +++ /dev/null @@ -1 +0,0 @@ -{"data":[]} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testGetReverseSortCollection.json b/elide-integration-tests/src/test/resources/ResourceIT/testGetReverseSortCollection.json deleted file mode 100644 index fa19ebe852..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testGetReverseSortCollection.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "data": [ - { - "type": "parent", - "id": "4", - "attributes": { - "firstName": "Unknown" - }, - "relationships": { - "children": { - "data": [] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "2", - "attributes": { - "firstName": "syzygy" - }, - "relationships": { - "children": { - "data": [ - { - "type": "child", - "id": "2" - }, - { - "type": "child", - "id": "3" - } - ] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "6", - "attributes": { - "firstName": "omg. I have kidz." - }, - "relationships": { - "children": { - "data": [ - { - "type": "child", - "id": "2" - } - ] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "3", - "attributes": { - "firstName": "Link" - }, - "relationships": { - "children": { - "data": [ - { - "type": "child", - "id": "4" - }, - { - "type": "child", - "id": "5" - } - ] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "5", - "attributes": { - "firstName": "I'm new here" - }, - "relationships": { - "children": { - "data": [ - { - "type": "child", - "id": "6" - } - ] - }, - "spouses": { - "data": [] - } - } - }, - { - "type": "parent", - "id": "1", - "attributes": { - "firstName": null - }, - "relationships": { - "children": { - "data": [ - { - "type": "child", - "id": "1" - }, - { - "type": "child", - "id": "6" - } - ] - }, - "spouses": { - "data": [] - } - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testGetSingleIncludeOnCollection.json b/elide-integration-tests/src/test/resources/ResourceIT/testGetSingleIncludeOnCollection.json deleted file mode 100644 index 97d156bb45..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testGetSingleIncludeOnCollection.json +++ /dev/null @@ -1,266 +0,0 @@ -{ - "data":[ - { - "type":"parent", - "id":"1", - "attributes":{ - "firstName":null - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"6" - }, - { - "type":"child", - "id":"1" - } - ] - } - } - }, - { - "type":"parent", - "id":"2", - "attributes":{ - "firstName":"syzygy" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"2" - }, - { - "type":"child", - "id":"3" - } - ] - } - } - }, - { - "type":"parent", - "id":"3", - "attributes":{ - "firstName":"Link" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"4" - }, - { - "type":"child", - "id":"5" - } - ] - } - } - }, - { - "type":"parent", - "id":"4", - "attributes":{ - "firstName":"Unknown" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[] - } - } - }, - { - "type":"parent", - "id":"5", - "attributes":{ - "firstName":"I'm new here" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"6" - } - ] - } - } - }, - { - "type":"parent", - "id":"6", - "attributes":{ - "firstName":"omg. I have kidz." - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"2" - } - ] - } - } - } - ], - "included":[ - { - "type":"child", - "id":"6", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"1" - }, - { - "type":"parent", - "id":"5" - } - ] - } - } - }, - { - "type":"child", - "id":"1", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"1" - } - ] - } - } - }, - { - "type":"child", - "id":"2", - "attributes":{ - "name":"Child-ID2" - }, - "relationships":{ - "friends":{ - "data":[ - { - "type":"child", - "id":"3" - } - ] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"2" - }, - { - "type":"parent", - "id":"6" - } - ] - } - } - }, - { - "type":"child", - "id":"3", - "attributes":{ - "name":"Child-ID3" - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"2" - } - ] - } - } - }, - { - "type":"child", - "id":"4", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"3" - } - ] - } - } - }, - { - "type":"child", - "id":"5", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"3" - } - ] - } - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testGetSingleIncludeOnRelationship.json b/elide-integration-tests/src/test/resources/ResourceIT/testGetSingleIncludeOnRelationship.json deleted file mode 100644 index 18bf72d617..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testGetSingleIncludeOnRelationship.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "data":[ - { - "type":"child", - "id":"1" - } - ], - "included":[ - { - "type":"child", - "id":"1", - "attributes":{ - "name":null - }, - "relationships":{ - "friends":{ - "data":[] - }, - "parents":{ - "data":[ - { - "type":"parent", - "id":"1" - } - ] - } - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testGetSortCollection.json b/elide-integration-tests/src/test/resources/ResourceIT/testGetSortCollection.json deleted file mode 100644 index 6cbd3815f2..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testGetSortCollection.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "data":[ - { - "type":"parent", - "id":"1", - "attributes":{ - "firstName":null - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"6" - }, - { - "type":"child", - "id":"1" - } - ] - } - } - }, - { - "type":"parent", - "id":"6", - "attributes":{ - "firstName":"omg. I have kidz." - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"2" - } - ] - } - } - }, - { - "type":"parent", - "id":"2", - "attributes":{ - "firstName":"syzygy" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"3" - }, - { - "type":"child", - "id":"2" - } - ] - } - } - }, - { - "type":"parent", - "id":"3", - "attributes":{ - "firstName":"Link" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"5" - }, - { - "type":"child", - "id":"4" - } - ] - } - } - }, - { - "type":"parent", - "id":"4", - "attributes":{ - "firstName":"Unknown" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[] - } - } - }, - { - "type":"parent", - "id":"5", - "attributes":{ - "firstName":"I'm new here" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"6" - } - ] - } - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testGetWithTrailingSlash.json b/elide-integration-tests/src/test/resources/ResourceIT/testGetWithTrailingSlash.json deleted file mode 100644 index 6cbd3815f2..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testGetWithTrailingSlash.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "data":[ - { - "type":"parent", - "id":"1", - "attributes":{ - "firstName":null - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"6" - }, - { - "type":"child", - "id":"1" - } - ] - } - } - }, - { - "type":"parent", - "id":"6", - "attributes":{ - "firstName":"omg. I have kidz." - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"2" - } - ] - } - } - }, - { - "type":"parent", - "id":"2", - "attributes":{ - "firstName":"syzygy" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"3" - }, - { - "type":"child", - "id":"2" - } - ] - } - } - }, - { - "type":"parent", - "id":"3", - "attributes":{ - "firstName":"Link" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"5" - }, - { - "type":"child", - "id":"4" - } - ] - } - } - }, - { - "type":"parent", - "id":"4", - "attributes":{ - "firstName":"Unknown" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[] - } - } - }, - { - "type":"parent", - "id":"5", - "attributes":{ - "firstName":"I'm new here" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"6" - } - ] - } - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchAttrList.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchAttrList.json deleted file mode 100644 index b551be14c2..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchAttrList.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "data":[ - { - "type":"parent", - "id":"3", - "attributes":{ - "firstName":"Senor" - } - }, - { - "type":"parent", - "id":"11", - "attributes":{ - "firstName":"woot" - } - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchAttrNoUpdateSingle.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchAttrNoUpdateSingle.json deleted file mode 100644 index 87cefbf337..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchAttrNoUpdateSingle.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"2", - "attributes":{ - "firstName":"syzygy" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchAttrSingle.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchAttrSingle.json deleted file mode 100644 index 87cefbf337..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchAttrSingle.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"2", - "attributes":{ - "firstName":"syzygy" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchDeferredOnCreate.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchDeferredOnCreate.json index 00848421a7..89535edf76 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchDeferredOnCreate.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/testPatchDeferredOnCreate.json @@ -1,5 +1,7 @@ { "errors": [ - "ForbiddenAccessException" + { + "detail" : "ReadPermission Denied" + } ] } diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoCommit.req.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoCommit.req.json index 9b273efe1a..51245932af 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoCommit.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoCommit.req.json @@ -4,7 +4,10 @@ "path":"/nocommit", "value":{ "type":"nocommit", - "id":"12345678-1234-1234-1234-123456789ab1" + "id":"12345678-1234-1234-1234-123456789ab1", + "attributes":{ + "value":"value" + } } } ] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoReadPermForNew.req.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoReadPermForNew.req.json index 367e3d4055..b30855a8a4 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoReadPermForNew.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoReadPermForNew.req.json @@ -3,7 +3,7 @@ "op": "add", "path": "/-", "value": { - "type": "specialRead", + "type": "specialread", "id": "12345678-1234-1234-1234-123456789ab1", "attributes": { "value": "special value" diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoReadPermForNew.resp.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoReadPermForNew.resp.json index a22b985ab4..5211966c8c 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoReadPermForNew.resp.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/testPatchExtNoReadPermForNew.resp.json @@ -10,7 +10,7 @@ "child": { "data": { "type": "child", - "id": "11" + "id": "6" } } } @@ -19,7 +19,7 @@ { "data": { "type": "child", - "id": "11", + "id": "6", "attributes": { "name": null }, diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelNoUpdateDirect.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelNoUpdateDirect.json deleted file mode 100644 index 354f6d7eb8..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelNoUpdateDirect.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "data":[ - { - "type":"child", - "id":"4" - }, - { - "type":"child", - "id":"5" - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelNoUpdateSingle.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelNoUpdateSingle.json deleted file mode 100644 index 7ab3fd5745..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelNoUpdateSingle.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"4", - "relationships":{ - "children":{ - "data":[ - { - "type":"child", - "id":"4" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelRemoveColl.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelRemoveColl.json deleted file mode 100644 index ec320a1688..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelRemoveColl.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"4", - "attributes":{ - "firstName":"Unknown" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelRemoveColl.req.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelRemoveColl.req.json deleted file mode 100644 index f185d391c5..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelRemoveColl.req.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"4", - "relationships":{ - "children":{ - "data":[] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelSetDirect.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelSetDirect.json deleted file mode 100644 index 354f6d7eb8..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRelSetDirect.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "data":[ - { - "type":"child", - "id":"4" - }, - { - "type":"child", - "id":"5" - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRemoveRelSingle.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchRemoveRelSingle.json deleted file mode 100644 index 747f8cdd1d..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRemoveRelSingle.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"4", - "attributes":{ - "firstName":"Unknown" - }, - "relationships":{ - "spouses":{ - "data":[] - }, - "children":{ - "data":[ - { - "type":"child", - "id":"4" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRemoveRelSingle.req.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchRemoveRelSingle.req.json deleted file mode 100644 index 7ab3fd5745..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchRemoveRelSingle.req.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"4", - "relationships":{ - "children":{ - "data":[ - { - "type":"child", - "id":"4" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPatchSetRel.json b/elide-integration-tests/src/test/resources/ResourceIT/testPatchSetRel.json deleted file mode 100644 index 71a79e680d..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPatchSetRel.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"4", - "relationships":{ - "children":{ - "data":[ - { - "type":"child", - "id":"4" - }, - { - "type":"child", - "id":"5" - } - ] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testPostWithInvalidRelationship.json b/elide-integration-tests/src/test/resources/ResourceIT/testPostWithInvalidRelationship.json deleted file mode 100644 index e96791ff5f..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testPostWithInvalidRelationship.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "data": { - "type": "resourceWithInvalidRelationship", - "id": "1", - "attributes": { - "name": "test123" - }, - "relationships": { - "notIncludedResouce": { - "data": { - "type": "notIncludedResource", - "id": "1" - } - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testRootCollectionId.json b/elide-integration-tests/src/test/resources/ResourceIT/testRootCollectionId.json deleted file mode 100644 index c28b01b2b3..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testRootCollectionId.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "data":{ - "type":"parent", - "id":"1", - "attributes":{ - "firstName":null - }, - "relationships":{ - "children":{ - "data":[ - { - "type":"child", - "id":"1" - } - ] - }, - "spouses":{ - "data":[] - } - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testRootCollectionRelationships.json b/elide-integration-tests/src/test/resources/ResourceIT/testRootCollectionRelationships.json deleted file mode 100644 index 7ebec426be..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testRootCollectionRelationships.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "data":[ - { - "type":"child", - "id":"1" - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/testSubCollectionRelationships.json b/elide-integration-tests/src/test/resources/ResourceIT/testSubCollectionRelationships.json deleted file mode 100644 index 0f756c6491..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/testSubCollectionRelationships.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "data":[ - { - "type":"parent", - "id":"1" - } - ] -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/updateChildRelationToExisting.2.json b/elide-integration-tests/src/test/resources/ResourceIT/updateChildRelationToExisting.2.json index 086cffa2af..69f3b918e9 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/updateChildRelationToExisting.2.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/updateChildRelationToExisting.2.json @@ -1,7 +1,7 @@ { "data":{ "type":"child", - "id":"8", + "id":"1", "attributes":{ "name":null }, @@ -13,7 +13,7 @@ "data":[ { "type":"parent", - "id":"5" + "id":"4" } ] } diff --git a/elide-integration-tests/src/test/resources/ResourceIT/updateChildRelationToExisting.req.json b/elide-integration-tests/src/test/resources/ResourceIT/updateChildRelationToExisting.req.json index a5ef0ed5d8..c41257b551 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/updateChildRelationToExisting.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/updateChildRelationToExisting.req.json @@ -1,16 +1,16 @@ [ { "op":"replace", - "path":"/7/children/8", + "path":"/1/children/1", "value":{ "type":"child", - "id":"8", + "id":"1", "relationships":{ "parents":{ "data":[ { "type":"parent", - "id":"5" + "id":"4" } ] } diff --git a/elide-integration-tests/src/test/resources/ResourceIT/updateOneToOneNonRoot.json b/elide-integration-tests/src/test/resources/ResourceIT/updateOneToOneNonRoot.json deleted file mode 100644 index a751c22634..0000000000 --- a/elide-integration-tests/src/test/resources/ResourceIT/updateOneToOneNonRoot.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "data":{ - "type":"OneToOneNonRoot", - "id":"2", - "attributes":{ - "test": "Updated object" - } - } -} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/updateRelationshipDirect.2.json b/elide-integration-tests/src/test/resources/ResourceIT/updateRelationshipDirect.2.json index 2d72737bae..1f5d6f9e26 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/updateRelationshipDirect.2.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/updateRelationshipDirect.2.json @@ -1,9 +1,9 @@ { "data":{ "type":"parent", - "id":"10", + "id":"1", "attributes":{ - "firstName":"bart" + "firstName":null }, "relationships":{ "spouses":{ @@ -13,7 +13,7 @@ "data":[ { "type":"child", - "id":"7" + "id":"1" }, { "type":"child", diff --git a/elide-integration-tests/src/test/resources/ResourceIT/updateRelationshipDirect.req.json b/elide-integration-tests/src/test/resources/ResourceIT/updateRelationshipDirect.req.json index 899eebcd67..3d45f4b07c 100644 --- a/elide-integration-tests/src/test/resources/ResourceIT/updateRelationshipDirect.req.json +++ b/elide-integration-tests/src/test/resources/ResourceIT/updateRelationshipDirect.req.json @@ -5,7 +5,7 @@ "value":[ { "type":"child", - "id":"7" + "id":"1" }, { "type":"child", diff --git a/elide-integration-tests/src/test/resources/ResourceIT/versionedPatchExtension.req.json b/elide-integration-tests/src/test/resources/ResourceIT/versionedPatchExtension.req.json new file mode 100644 index 0000000000..9e400d9bab --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/versionedPatchExtension.req.json @@ -0,0 +1,14 @@ +[ + { + "op": "add", + "path": "/-", + "value": { + "type": "book", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "name": "A new book.", + "publishDate" : 1234 + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/versionedPatchExtension.resp.json b/elide-integration-tests/src/test/resources/ResourceIT/versionedPatchExtension.resp.json new file mode 100644 index 0000000000..8c3cf8ceb0 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/versionedPatchExtension.resp.json @@ -0,0 +1,12 @@ +[ + { + "data": { + "type": "book", + "id": "3", + "attributes": { + "name": "A new book.", + "publishDate": 1234 + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher1.json b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher1.json new file mode 100644 index 0000000000..560a693ebf --- /dev/null +++ b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher1.json @@ -0,0 +1,117 @@ +[ + + { + "op": "add", + "path": "/author", + "value": { + "id": "12345678-1234-1234-1234-1234567890ab", + "type": "author", + "attributes": { + "name": "Ernest Hemingway", + "homeAddress": "main" + }, + "relationships": { + "books": { + "data": [ + { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ac" + }, + { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ad" + } + ] + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ac", + "attributes": { + "title": "The Old Man and the Sea", + "genre": "Literary Fiction", + "language": "English", + "price": { + "total": 10.0, + "currency" : { + "isoCode" : "USD" + } + } + }, + "relationships": { + "publisher": { + "data": { + "type": "publisher", + "id": "12345678-1234-1234-1234-1234567890ae" + } + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345678-1234-1234-1234-1234567890ad", + "attributes": { + "title": "For Whom the Bell Tolls", + "genre": "Literary Fiction", + "language": "English", + "price": { + "total": 9.0, + "currency" : { + "isoCode" : "AED" + } + } + }, + "relationships": { + "publisher": { + "data": { + "type": "publisher", + "id": "12345678-1234-1234-1234-1234567890af" + } + } + } + } + }, + { + "op": "add", + "path": "/book/12345678-1234-1234-1234-1234567890ac/publisher", + "value": { + "type": "publisher", + "id": "12345678-1234-1234-1234-1234567890ae", + "attributes": { + "name": "Default publisher" + } + } + }, + { + "op": "add", + "path": "/book/12345678-1234-1234-1234-1234567890ad/publisher", + "value": { + "type": "publisher", + "id": "12345678-1234-1234-1234-1234567890af", + "attributes": { + "name": "Super Publisher" + } + } + }, + { + "op": "add", + "path": "/book/12345678-1234-1234-1234-1234567890ad/publisher/12345678-1234-1234-1234-1234567890af/editor", + "value": { + "type": "editor", + "id": "12345678-1234-1234-1234-1234567890ba", + "attributes": { + "firstName": "John", + "lastName": "Doe" + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher2.json b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher2.json new file mode 100644 index 0000000000..a28294e0ed --- /dev/null +++ b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher2.json @@ -0,0 +1,55 @@ +[ + { + "op": "add", + "path": "/author", + "value": { + "id": "12345679-1234-1234-1234-1234567890ab", + "type": "author", + "attributes": { + "name": "Orson Scott Card" + }, + "relationships": { + "books": { + "data": [ + { + "type": "book", + "id": "12345679-1234-1234-1234-1234567890ac" + }, + { + "type": "book", + "id": "23451234-1234-1234-1234-1234567890ac" + } + ] + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345679-1234-1234-1234-1234567890ac", + "attributes": { + "title": "Enders Game", + "genre": "Science Fiction", + "language": "English", + "publishDate": 1454638927412 + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "23451234-1234-1234-1234-1234567890ac", + "attributes": { + "title": "Enders Shadow", + "genre": "Science Fiction", + "language": "English", + "publishDate": 1464638927412 + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher3.json b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher3.json new file mode 100644 index 0000000000..e509a7b4cc --- /dev/null +++ b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher3.json @@ -0,0 +1,53 @@ +[ + { + "op": "add", + "path": "/author", + "value": { + "id": "12345680-1234-1234-1234-1234567890ab", + "type": "author", + "attributes": { + "name": "Isaac Asimov" + }, + "relationships": { + "books": { + "data": [ + { + "type": "book", + "id": "12345680-1234-1234-1234-1234567890ac" + }, + { + "type": "book", + "id": "12345680-1234-1234-1234-1234567890ad" + } + ] + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345680-1234-1234-1234-1234567890ac", + "attributes": { + "title": "Foundation", + "genre": "Science Fiction", + "language": "English" + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345680-1234-1234-1234-1234567890ad", + "attributes": { + "title": "The Roman Republic", + "genre": "History", + "language": "English" + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher4.json b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher4.json new file mode 100644 index 0000000000..df73473b64 --- /dev/null +++ b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher4.json @@ -0,0 +1,52 @@ +[ + { + "op": "add", + "path": "/author", + "value": { + "id": "12345681-1234-1234-1234-1234567890ab", + "type": "author", + "attributes": { + "name": "Null Ned" + }, + "relationships": { + "books": { + "data": [ + { + "type": "book", + "id": "12345681-1234-1234-1234-1234567890ac" + }, + { + "type": "book", + "id": "12345681-1234-1234-1234-1234567890ad" + } + ] + } + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345681-1234-1234-1234-1234567890ac", + "attributes": { + "title": "Life with Null Ned", + "language": "English" + } + } + }, + { + "op": "add", + "path": "/book", + "value": { + "type": "book", + "id": "12345681-1234-1234-1234-1234567890ad", + "attributes": { + "title": "Life with Null Ned 2", + "genre": "Not Null", + "language": "English" + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/hibernate.cfg.xml b/elide-integration-tests/src/test/resources/hibernate.cfg.xml index e1fb9de958..b6f10963d2 100644 --- a/elide-integration-tests/src/test/resources/hibernate.cfg.xml +++ b/elide-integration-tests/src/test/resources/hibernate.cfg.xml @@ -1,6 +1,6 @@ @@ -9,18 +9,17 @@ "-//Hibernate/Hibernate Configuration DTD 3.0//EN" "http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd"> - + - com.mysql.jdbc.Driver - - org.hibernate.dialect.MySQL5InnoDBDialect - + org.h2.Driver + + org.hibernate.dialect.H2Dialect 50 50 - true + false org.hibernate.transaction.JDBCTransactionFactory @@ -30,5 +29,8 @@ false thread + + 60 + true diff --git a/elide-intellij-codestyle.xml b/elide-intellij-codestyle.xml index 8c790a71b0..47ded9c562 100644 --- a/elide-intellij-codestyle.xml +++ b/elide-intellij-codestyle.xml @@ -10,6 +10,8 @@ + + @@ -29,6 +31,8 @@ + + diff --git a/elide-logo.svg b/elide-logo.svg new file mode 100644 index 0000000000..1913423f53 --- /dev/null +++ b/elide-logo.svg @@ -0,0 +1,17 @@ + + + + elide-logo + Created with Sketch. + + + + + \ No newline at end of file diff --git a/elide-model-config/README.md b/elide-model-config/README.md new file mode 100644 index 0000000000..6c3ae13cd9 --- /dev/null +++ b/elide-model-config/README.md @@ -0,0 +1,31 @@ +## Validation for Dynamic Config + +Validate the config files in local before deployment. + +To build and run: +```text +1. mvn clean install +2. Execute Jar File : + a) java -cp elide-spring/elide-spring-boot-autoconfigure/target/elide-spring-boot-autoconfigure-*.jar DynamicConfigValidator --configDir + java -cp elide-standalone/target/elide-standalone-*.jar DynamicConfigValidator --configDir + b) java -cp elide-spring/elide-spring-boot-autoconfigure/target/elide-spring-boot-autoconfigure-*.jar DynamicConfigValidator --help + java -cp elide-standalone/target/elide-standalone-*.jar DynamicConfigValidator --help +``` +Expected Configs Directory Structure: +```text +├── CONFIG_DIR/ +│ ├── models +│ │ ├── tables +│ │ │ ├── table1.hjson +│ │ │ ├── table2.hjson +│ │ │ ├── ... +│ │ │ ├── tableN.hjson +│ │ ├── security.hjson (optional) +│ │ ├── variables.hjson (optional) +│ ├── db +│ │ ├── sql (optional) +│ │ │ ├── db1.hjson +│ │ │ ├── ... +│ │ │ ├── dbN.hjson +│ │ ├── variables.hjson (optional) +``` diff --git a/elide-model-config/pom.xml b/elide-model-config/pom.xml new file mode 100644 index 0000000000..545fab04d1 --- /dev/null +++ b/elide-model-config/pom.xml @@ -0,0 +1,192 @@ + + + + 4.0.0 + elide-model-config + jar + Elide Dynamic Model Config + Elide Dynamic Model Config + https://github.com/yahoo/elide + + elide-parent-pom + com.yahoo.elide + 6.1.4-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 + 4.3.0 + 2.2.14 + 2.11.0 + 1.5.0 + 1.21 + 5.3.18 + + + + + com.yahoo.elide + elide-core + 6.1.4-SNAPSHOT + + + 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-cli + commons-cli + ${commons-cli.version} + + + commons-io + commons-io + ${commons-io.version} + + + org.apache.commons + commons-compress + ${commons-compress.version} + + + org.springframework + spring-core + ${spring-core.verions} + + + org.slf4j + slf4j-api + + + org.projectlombok + lombok + + + + + javax.persistence + javax.persistence-api + provided + + + + com.github.java-json-tools + json-schema-validator + ${json-schema-validator.version} + + + ch.qos.logback + logback-classic + test + + + org.hjson + hjson + ${hjson.version} + + + com.github.stefanbirkner + system-lambda + 1.2.1 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + ${version.junit} + test + + + org.mockito + mockito-core + test + + + com.github.jknack + handlebars-helpers + ${handlebars.version} + + + org.junit.platform + junit-platform-launcher + test + + + 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 + + + + diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/Config.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/Config.java new file mode 100644 index 0000000000..01d030269f --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/Config.java @@ -0,0 +1,58 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig; + +/** + * Dynamic Config enum. + */ +public enum Config { + + TABLE("table", + "models/tables/", + "/elideTableSchema.json"), + + SECURITY("security", + "models/security.hjson", + "/elideSecuritySchema.json"), + + MODELVARIABLE("variable", + "models/variables.hjson", + "/elideVariableSchema.json"), + + DBVARIABLE("variable", + "db/variables.hjson", + "/elideVariableSchema.json"), + + SQLDBConfig("sqldbconfig", + "db/sql/", + "/elideDBConfigSchema.json"), + + NAMESPACEConfig("namespaceconfig", + "models/namespaces/", + "/elideNamespaceConfigSchema.json"); + + private final String configType; + private final String configPath; + private final String configSchema; + + private Config(String configType, String configPath, String configSchema) { + this.configPath = configPath; + this.configType = configType; + this.configSchema = configSchema; + } + + public String getConfigType() { + return configType; + } + + public String getConfigPath() { + return configPath; + } + + public String getConfigSchema() { + return configSchema; + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DBPasswordExtractor.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DBPasswordExtractor.java new file mode 100644 index 0000000000..bc187f3cd4 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DBPasswordExtractor.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig; + +import com.yahoo.elide.modelconfig.model.DBConfig; + +/** + * Interface for providing password extractor implementation. + */ +public interface DBPasswordExtractor { + /** + * Extract password for connecting to DB. + * @param config DB Config POJO. + * @return String DB Connection Password. + */ + String getDBPassword(DBConfig config); +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DraftV4LibraryWithElideFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DraftV4LibraryWithElideFormatAttr.java new file mode 100644 index 0000000000..29bb16f4bc --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DraftV4LibraryWithElideFormatAttr.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig; + +import com.yahoo.elide.modelconfig.jsonformats.ElideArgumentNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideCardinalityFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideFieldNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideFieldTypeFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideGrainTypeFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideJDBCUrlFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideJoinKindFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideJoinTypeFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideNamespaceNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideRSQLFilterFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideRoleFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideTimeFieldTypeFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.JavaClassNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.JavaClassNameWithExtFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ValidateArgsPropertiesKeyword; +import com.yahoo.elide.modelconfig.jsonformats.ValidateDimPropertiesKeyword; +import com.yahoo.elide.modelconfig.jsonformats.ValidateTimeDimPropertiesKeyword; +import com.github.fge.jsonschema.library.DraftV4Library; +import com.github.fge.jsonschema.library.Library; +import lombok.Getter; + +/** + * Augment the {@link DraftV4Library} with custom format attributes and keywords. + */ +public class DraftV4LibraryWithElideFormatAttr { + @Getter + private Library library; + + public DraftV4LibraryWithElideFormatAttr() { + library = DraftV4Library.get().thaw() + .addFormatAttribute(ElideFieldNameFormatAttr.FORMAT_NAME, new ElideFieldNameFormatAttr()) + .addFormatAttribute(ElideArgumentNameFormatAttr.FORMAT_NAME, new ElideArgumentNameFormatAttr()) + .addFormatAttribute(ElideCardinalityFormatAttr.FORMAT_NAME, new ElideCardinalityFormatAttr()) + .addFormatAttribute(ElideFieldTypeFormatAttr.FORMAT_NAME, new ElideFieldTypeFormatAttr()) + .addFormatAttribute(ElideGrainTypeFormatAttr.FORMAT_NAME, new ElideGrainTypeFormatAttr()) + .addFormatAttribute(ElideJoinTypeFormatAttr.FORMAT_NAME, new ElideJoinTypeFormatAttr()) + .addFormatAttribute(ElideJoinKindFormatAttr.FORMAT_NAME, new ElideJoinKindFormatAttr()) + .addFormatAttribute(ElideTimeFieldTypeFormatAttr.FORMAT_NAME, + new ElideTimeFieldTypeFormatAttr()) + .addFormatAttribute(ElideNameFormatAttr.FORMAT_NAME, new ElideNameFormatAttr()) + .addFormatAttribute(ElideNamespaceNameFormatAttr.FORMAT_NAME, + new ElideNamespaceNameFormatAttr()) + .addFormatAttribute(ElideRSQLFilterFormatAttr.FORMAT_NAME, new ElideRSQLFilterFormatAttr()) + .addFormatAttribute(JavaClassNameWithExtFormatAttr.FORMAT_NAME, + new JavaClassNameWithExtFormatAttr()) + .addFormatAttribute(ElideJDBCUrlFormatAttr.FORMAT_NAME, new ElideJDBCUrlFormatAttr()) + .addFormatAttribute(JavaClassNameFormatAttr.FORMAT_NAME, new JavaClassNameFormatAttr()) + .addFormatAttribute(ElideRoleFormatAttr.FORMAT_NAME, new ElideRoleFormatAttr()) + .addKeyword(new ValidateDimPropertiesKeyword().getKeyword()) + .addKeyword(new ValidateTimeDimPropertiesKeyword().getKeyword()) + .addKeyword(new ValidateArgsPropertiesKeyword().getKeyword()) + .freeze(); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DynamicConfigHelpers.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DynamicConfigHelpers.java new file mode 100644 index 0000000000..3198dc448f --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DynamicConfigHelpers.java @@ -0,0 +1,183 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig; + +import com.yahoo.elide.modelconfig.model.ElideDBConfig; +import com.yahoo.elide.modelconfig.model.ElideNamespaceConfig; +import com.yahoo.elide.modelconfig.model.ElideSecurityConfig; +import com.yahoo.elide.modelconfig.model.ElideTableConfig; +import com.yahoo.elide.modelconfig.parser.handlebars.HandlebarsHydrator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import org.apache.commons.lang3.StringUtils; +import org.hjson.JsonValue; +import org.hjson.ParseException; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +/** + * Util class for Dynamic config helper module. + */ +public class DynamicConfigHelpers { + + /** + * format config file path. + * @param basePath : path to hjson config. + * @return formatted file path. + */ + public static String formatFilePath(String basePath) { + if (StringUtils.isNotBlank(basePath) && !basePath.endsWith("/")) { + basePath += "/"; + } + return basePath; + } + + /** + * converts variables hjson string to map of variables. + * @param config HJSON file content. + * @param schemaValidator JSON schema validator. + * @return Map of Variables + * @throws IOException If an I/O error or a processing error occurs. + */ + @SuppressWarnings("unchecked") + public static Map stringToVariablesPojo(String fileName, String config, + DynamicConfigSchemaValidator schemaValidator) throws IOException { + Map variables = new HashMap<>(); + String jsonConfig = hjsonToJson(config); + try { + if (schemaValidator.verifySchema(Config.MODELVARIABLE, jsonConfig, fileName)) { + variables = getModelPojo(jsonConfig, Map.class); + } + } catch (ProcessingException e) { + log.error("Error Validating Variable config : " + e.getMessage()); + throw new IOException(e); + } + return variables; + } + + /** + * Generates ElideTableConfig Pojo from input String. + * @param content : input string + * @param variables : variables to resolve. + * @param schemaValidator JSON schema validator. + * @return ElideTableConfig Pojo + * @throws IOException If an I/O error or a processing error occurs. + */ + public static ElideTableConfig stringToElideTablePojo(String fileName, String content, + Map variables, DynamicConfigSchemaValidator schemaValidator) throws IOException { + ElideTableConfig table = new ElideTableConfig(); + String jsonConfig = hjsonToJson(resolveVariables(content, variables)); + try { + if (schemaValidator.verifySchema(Config.TABLE, jsonConfig, fileName)) { + table = getModelPojo(jsonConfig, ElideTableConfig.class); + } + } catch (ProcessingException e) { + log.error("Error Validating Table config : " + e.getMessage()); + throw new IOException(e); + } + return table; + } + + /** + * Generates ElideDBConfig Pojo from input String. + * @param content : input string + * @param variables : variables to resolve. + * @param schemaValidator JSON schema validator. + * @return ElideDBConfig Pojo + * @throws IOException If an I/O error or a processing error occurs. + */ + public static ElideDBConfig stringToElideDBConfigPojo(String fileName, String content, + Map variables, DynamicConfigSchemaValidator schemaValidator) throws IOException { + ElideDBConfig dbconfig = new ElideDBConfig(); + String jsonConfig = hjsonToJson(resolveVariables(content, variables)); + try { + if (schemaValidator.verifySchema(Config.SQLDBConfig, jsonConfig, fileName)) { + dbconfig = getModelPojo(jsonConfig, ElideDBConfig.class); + } + } catch (ProcessingException e) { + log.error("Error Validating DB config : " + e.getMessage()); + throw new IOException(e); + } + return dbconfig; + } + + /** + * Generates ElideNamespaceConfig Pojo from input String. + * @param content : input string + * @param variables : variables to resolve. + * @param schemaValidator JSON schema validator. + * @return ElideNamespaceConfig Pojo + * @throws IOException If an I/O error or a processing error occurs. + */ + public static ElideNamespaceConfig stringToElideNamespaceConfigPojo(String fileName, String content, + Map variables, DynamicConfigSchemaValidator schemaValidator) throws IOException { + ElideNamespaceConfig namespaceconfig = new ElideNamespaceConfig(); + String jsonConfig = hjsonToJson(resolveVariables(content, variables)); + try { + if (schemaValidator.verifySchema(Config.NAMESPACEConfig, jsonConfig, fileName)) { + namespaceconfig = getModelPojo(jsonConfig, ElideNamespaceConfig.class); + } + } catch (ProcessingException e) { + log.error("Error Validating DB config : " + e.getMessage()); + throw new IOException(e); + } + return namespaceconfig; + } + + /** + * Generates ElideSecurityConfig Pojo from input String. + * @param content : input string + * @param variables : variables to resolve. + * @param schemaValidator JSON schema validator. + * @return ElideSecurityConfig Pojo + * @throws IOException If an I/O error or a processing error occurs. + */ + public static ElideSecurityConfig stringToElideSecurityPojo(String fileName, String content, + Map variables, DynamicConfigSchemaValidator schemaValidator) throws IOException { + String jsonConfig = hjsonToJson(resolveVariables(content, variables)); + try { + if (schemaValidator.verifySchema(Config.SECURITY, jsonConfig, fileName)) { + return getModelPojo(jsonConfig, ElideSecurityConfig.class); + } + } catch (ProcessingException e) { + log.error("Error Validating Security config : " + e.getMessage()); + throw new IOException(e); + } + 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 If an I/O error or a processing error occurs. + */ + 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) { + try { + return JsonValue.readHjson(hjson).toString(); + } catch (ParseException e) { + throw new IllegalStateException("Invalid Hjson Syntax: " + e.getMessage()); + } + } + + private static T getModelPojo(String jsonConfig, final Class configPojo) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS); + return objectMapper.readValue(jsonConfig, configPojo); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidator.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidator.java new file mode 100644 index 0000000000..ca7f409e81 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidator.java @@ -0,0 +1,164 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.github.fge.jsonschema.cfg.ValidationConfiguration; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.LogLevel; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.library.Library; +import com.github.fge.jsonschema.main.JsonSchema; +import com.github.fge.jsonschema.main.JsonSchemaFactory; +import com.github.fge.msgsimple.bundle.MessageBundle; +import org.apache.commons.lang3.StringUtils; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map.Entry; + +/** + * Dynamic Model Schema validation. + */ +@Slf4j +public class DynamicConfigSchemaValidator { + + private JsonSchema tableSchema; + private JsonSchema securitySchema; + private JsonSchema variableSchema; + private JsonSchema dbConfigSchema; + private JsonSchema namespaceConfigSchema; + private static String NEWLINE = System.lineSeparator(); + + public DynamicConfigSchemaValidator() { + + Library library = new DraftV4LibraryWithElideFormatAttr().getLibrary(); + + MessageBundle bundle = new MessageBundleWithElideMessages().getMsgBundle(); + + ValidationConfiguration cfg = ValidationConfiguration.newBuilder() + .setDefaultLibrary("http://my.site/myschema#", library) + .setValidationMessages(bundle) + .freeze(); + + JsonSchemaFactory factory = JsonSchemaFactory.newBuilder() + .setValidationConfiguration(cfg) + .freeze(); + + tableSchema = loadSchema(factory, Config.TABLE.getConfigSchema()); + securitySchema = loadSchema(factory, Config.SECURITY.getConfigSchema()); + variableSchema = loadSchema(factory, Config.MODELVARIABLE.getConfigSchema()); + dbConfigSchema = loadSchema(factory, Config.SQLDBConfig.getConfigSchema()); + namespaceConfigSchema = loadSchema(factory, Config.NAMESPACEConfig.getConfigSchema()); + } + + /** + * Verify config against schema. + * @param configType {@link Config} type. + * @param jsonConfig HJSON file content as JSON string. + * @param fileName Name of HJSON file. + * @return whether config is valid + * @throws IOException If an I/O error occurs. + * @throws ProcessingException If a processing error occurred during validation. + */ + public boolean verifySchema(Config configType, String jsonConfig, String fileName) + throws IOException, ProcessingException { + ProcessingReport results = null; + switch (configType) { + case TABLE : + results = this.tableSchema.validate(new ObjectMapper().readTree(jsonConfig), true); + break; + case SECURITY : + results = this.securitySchema.validate(new ObjectMapper().readTree(jsonConfig), true); + break; + case MODELVARIABLE : + case DBVARIABLE : + results = this.variableSchema.validate(new ObjectMapper().readTree(jsonConfig), true); + break; + case SQLDBConfig : + results = this.dbConfigSchema.validate(new ObjectMapper().readTree(jsonConfig), true); + break; + case NAMESPACEConfig : + results = this.namespaceConfigSchema.validate(new ObjectMapper().readTree(jsonConfig), true); + break; + default : + log.error("Not a valid config type :" + configType); + break; + } + if (results == null || !results.isSuccess()) { + throw new IllegalStateException("Schema validation failed for: " + fileName + getErrorMessages(results)); + } + return true; + } + + private static String getErrorMessages(ProcessingReport report) { + if (report == null) { + return null; + } + List list = new ArrayList<>(); + report.forEach(msg -> addEmbeddedMessages(msg.asJson(), list, 0)); + + return NEWLINE + String.join(NEWLINE, list); + } + + private static void addEmbeddedMessages(JsonNode root, List list, int depth) { + + if (root.has("level") && root.has("message")) { + String level = root.get("level").asText(); + + if (level.equalsIgnoreCase(LogLevel.ERROR.name()) || level.equalsIgnoreCase(LogLevel.FATAL.name())) { + String msg = root.get("message").asText(); + String instancePointer = extractPointer(root, "instance"); + String schemaPointer = extractPointer(root, "schema"); + + if (StringUtils.isNoneBlank(instancePointer, schemaPointer)) { + msg = "Instance[" + instancePointer + "] failed to validate against schema[" + schemaPointer + "]. " + + msg; + } + list.add((depth == 0) ? "[ERROR]" + NEWLINE + msg + : String.format("%" + (4 * depth + msg.length()) + "s", msg)); + + if (root.has("reports")) { + Iterator> fields = root.get("reports").fields(); + while (fields.hasNext()) { + ArrayNode arrayNode = (ArrayNode) fields.next().getValue(); + for (int i = 0; i < arrayNode.size(); i++) { + addEmbeddedMessages(arrayNode.get(i), list, depth + 1); + } + } + } + } + } + } + + private static String extractPointer(JsonNode root, String fieldName) { + String pointer = null; + if (root.has(fieldName)) { + JsonNode node = root.get(fieldName); + if (node.has("pointer")) { + pointer = node.get("pointer").asText(); + } + } + + return pointer; + } + + private static JsonSchema loadSchema(JsonSchemaFactory factory, String resource) { + + try (InputStream is = DynamicConfigHelpers.class.getResourceAsStream(resource)) { + return factory.getJsonSchema(new ObjectMapper().readTree(is)); + } catch (IOException | ProcessingException e) { + log.error("Error loading schema file " + resource + " to verify"); + throw new IllegalStateException(e.getMessage()); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DynamicConfiguration.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DynamicConfiguration.java new file mode 100644 index 0000000000..40da6f6bbb --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/DynamicConfiguration.java @@ -0,0 +1,53 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig; + +import com.yahoo.elide.modelconfig.model.DBConfig; +import com.yahoo.elide.modelconfig.model.NamespaceConfig; +import com.yahoo.elide.modelconfig.model.Table; + +import java.util.HashSet; +import java.util.Set; + +/** + * Returns the model, security role, and database connection configurations derived from HJSON + * files or other dynamic configuration systems. + */ +public interface DynamicConfiguration { + + /** + * Returns the set of dynamically configured tables. + * @return a set of tables. + */ + default Set getTables() { + return new HashSet<>(); + } + + /** + * Return the set of role names leveraged by dynamic models. + * @return a set of role names. + */ + default Set getRoles() { + return new HashSet<>(); + } + + /** + * Returns a set of database connection configurations. + * @return a set of database configurations. + */ + default Set getDatabaseConfigurations() { + return new HashSet<>(); + } + + /** + * Returns a set of Namespace configurations. + * @return a set of Namespace configurations. + */ + default Set getNamespaceConfigurations() { + return new HashSet<>(); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/MessageBundleWithElideMessages.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/MessageBundleWithElideMessages.java new file mode 100644 index 0000000000..ade9e09e65 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/MessageBundleWithElideMessages.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.modelconfig; + +import com.yahoo.elide.modelconfig.jsonformats.ElideArgumentNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideCardinalityFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideFieldNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideFieldTypeFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideGrainTypeFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideJDBCUrlFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideJoinKindFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideJoinTypeFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideNamespaceNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideRSQLFilterFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideRoleFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ElideTimeFieldTypeFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.JavaClassNameFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.JavaClassNameWithExtFormatAttr; +import com.yahoo.elide.modelconfig.jsonformats.ValidateArgsPropertiesValidator; +import com.yahoo.elide.modelconfig.jsonformats.ValidateDimPropertiesValidator; +import com.yahoo.elide.modelconfig.jsonformats.ValidateTimeDimPropertiesValidator; +import com.github.fge.jsonschema.messages.JsonSchemaValidationBundle; +import com.github.fge.msgsimple.bundle.MessageBundle; +import com.github.fge.msgsimple.load.MessageBundles; +import com.github.fge.msgsimple.source.MapMessageSource; +import lombok.Getter; + +/** + * Augment the {@link MessageBundle} with error messages for custom format attributes and keywords. + */ +public class MessageBundleWithElideMessages { + @Getter + private MessageBundle msgBundle; + + public MessageBundleWithElideMessages() { + this.msgBundle = MessageBundles.getBundle(JsonSchemaValidationBundle.class).thaw() + .appendSource(MapMessageSource.newBuilder() + + // For Format errors + .put(ElideFieldNameFormatAttr.FORMAT_KEY, ElideFieldNameFormatAttr.FORMAT_MSG) + .put(ElideFieldNameFormatAttr.NAME_KEY, ElideFieldNameFormatAttr.NAME_MSG) + .put(ElideArgumentNameFormatAttr.FORMAT_KEY, + ElideArgumentNameFormatAttr.FORMAT_MSG) + .put(ElideArgumentNameFormatAttr.NAME_KEY, ElideArgumentNameFormatAttr.NAME_MSG) + .put(ElideCardinalityFormatAttr.TYPE_KEY, ElideCardinalityFormatAttr.TYPE_MSG) + .put(ElideFieldTypeFormatAttr.TYPE_KEY, ElideFieldTypeFormatAttr.TYPE_MSG) + .put(ElideGrainTypeFormatAttr.TYPE_KEY, ElideGrainTypeFormatAttr.TYPE_MSG) + .put(ElideJoinTypeFormatAttr.TYPE_KEY, ElideJoinTypeFormatAttr.TYPE_MSG) + .put(ElideJoinKindFormatAttr.TYPE_KEY, ElideJoinKindFormatAttr.TYPE_MSG) + .put(ElideTimeFieldTypeFormatAttr.TYPE_KEY, + ElideTimeFieldTypeFormatAttr.TYPE_MSG) + .put(ElideNameFormatAttr.FORMAT_KEY, ElideNameFormatAttr.FORMAT_MSG) + .put(ElideNamespaceNameFormatAttr.FORMAT_KEY, + ElideNamespaceNameFormatAttr.FORMAT_KEY_MSG) + .put(ElideNamespaceNameFormatAttr.FORMAT_ADDITIONAL_KEY, + ElideNamespaceNameFormatAttr.FORMAT_ADDITIONAL_KEY_MSG) + .put(ElideRSQLFilterFormatAttr.FORMAT_KEY, ElideRSQLFilterFormatAttr.FORMAT_MSG) + .put(JavaClassNameWithExtFormatAttr.FORMAT_KEY, + JavaClassNameWithExtFormatAttr.FORMAT_MSG) + .put(JavaClassNameFormatAttr.FORMAT_KEY, JavaClassNameFormatAttr.FORMAT_MSG) + .put(ElideJDBCUrlFormatAttr.FORMAT_KEY, ElideJDBCUrlFormatAttr.FORMAT_MSG) + .put(ElideRoleFormatAttr.FORMAT_KEY, ElideRoleFormatAttr.FORMAT_MSG) + + // for Keyword errors + .put(ValidateDimPropertiesValidator.ATMOST_ONE_KEY, + ValidateDimPropertiesValidator.ATMOST_ONE_MSG) + .put(ValidateDimPropertiesValidator.ADDITIONAL_KEY, + ValidateDimPropertiesValidator.ADDITIONAL_MSG) + .put(ValidateTimeDimPropertiesValidator.ADDITIONAL_KEY, + ValidateTimeDimPropertiesValidator.ADDITIONAL_MSG) + .put(ValidateArgsPropertiesValidator.ATMOST_ONE_KEY, + ValidateArgsPropertiesValidator.ATMOST_ONE_MSG) + .build()) + .freeze(); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/io/FileLoader.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/io/FileLoader.java new file mode 100644 index 0000000000..44e0600bde --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/io/FileLoader.java @@ -0,0 +1,174 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.io; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static java.nio.charset.StandardCharsets.UTF_8; +import com.yahoo.elide.modelconfig.DynamicConfigHelpers; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; + +import org.apache.commons.io.IOUtils; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * Responsible for loading HJSON configuration either from the classpath or from the file system. + */ +@Slf4j +public class FileLoader { + private static final Pattern TABLE_FILE = Pattern.compile("models/tables/[^/]+\\.hjson"); + private static final Pattern NAME_SPACE_FILE = Pattern.compile("models/namespaces/[^/]+\\.hjson"); + private static final Pattern DB_FILE = Pattern.compile("db/sql/[^/]+\\.hjson"); + private static final String CLASSPATH_PATTERN = "classpath*:"; + private static final String FILEPATH_PATTERN = "file:"; + private static final String RESOURCES = "resources"; + private static final int RESOURCES_LENGTH = 9; //"resources".length() + + private static final String HJSON_EXTN = "**/*.hjson"; + + private final PathMatchingResourcePatternResolver resolver; + + private static final Function CONTENT_PROVIDER = (resource) -> { + try { + return IOUtils.toString(resource.getInputStream(), UTF_8); + } catch (IOException e) { + log.error ("Error converting stream to String: {}", e.getMessage()); + throw new IllegalStateException(e); + } + }; + + @Getter + private final String rootPath; + private final String rootURL; + + @Getter + private boolean writeable; + + /** + * Constructor. + * @param rootPath The file system (or classpath) root directory path for configuration. + */ + public FileLoader(String rootPath) { + this.resolver = new PathMatchingResourcePatternResolver(this.getClass().getClassLoader()); + + String pattern = CLASSPATH_PATTERN + DynamicConfigHelpers.formatFilePath(formatClassPath(rootPath)); + + boolean classPathExists = false; + try { + classPathExists = (resolver.getResources(pattern).length != 0); + } catch (IOException e) { + //NOOP + } + + if (classPathExists) { + this.rootURL = pattern; + writeable = false; + } else { + File config = new File(rootPath); + if (!config.exists()) { + log.error ("Config path does not exist: {}", config); + throw new IllegalStateException(rootPath + " : config path does not exist"); + } + + writeable = Files.isWritable(config.toPath()); + this.rootURL = FILEPATH_PATTERN + DynamicConfigHelpers.formatFilePath(config.getAbsolutePath()); + } + + this.rootPath = rootPath; + } + + /** + * Load resources from the filesystem/classpath. + * @return A map from the path to the resource. + * @throws IOException If something goes boom. + */ + public Map loadResources() throws IOException { + Map resourceMap = new LinkedHashMap<>(); + int configDirURILength = resolver.getResources(this.rootURL)[0].getURI().toString().length(); + + Resource[] hjsonResources = resolver.getResources(this.rootURL + HJSON_EXTN); + for (Resource resource : hjsonResources) { + if (! resource.exists()) { + log.error("Missing resource during HJSON configuration load: {}", resource.getURI()); + continue; + } + String path = resource.getURI().toString().substring(configDirURILength); + resourceMap.put(path, ConfigFile.builder() + .type(toType(path)) + .contentProvider(() -> CONTENT_PROVIDER.apply(resource)) + .path(path) + .version(NO_VERSION) + .build()); + } + + return resourceMap; + } + + /** + * Load a single resource from the filesystem/classpath. + * @return The file content. + * @throws IOException If something goes boom. + */ + public ConfigFile loadResource(String relativePath) throws IOException { + Resource[] hjsonResources = resolver.getResources(this.rootURL + relativePath); + if (hjsonResources.length == 0 || ! hjsonResources[0].exists()) { + return null; + } + + return ConfigFile.builder() + .type(toType(relativePath)) + .contentProvider(() -> CONTENT_PROVIDER.apply(hjsonResources[0])) + .path(relativePath) + .version(NO_VERSION) + .build(); + } + + /** + * Remove src/.../resources/ from class path for configs directory. + * @param filePath class path for configs directory. + * @return formatted class path for configs directory. + */ + static String formatClassPath(String filePath) { + if (filePath.indexOf(RESOURCES + "/") > -1) { + return filePath.substring(filePath.indexOf(RESOURCES + "/") + RESOURCES_LENGTH + 1); + } else if (filePath.indexOf(RESOURCES) > -1) { + return filePath.substring(filePath.indexOf(RESOURCES) + RESOURCES_LENGTH); + } + return filePath; + } + + public static ConfigFile.ConfigFileType toType(String path) { + String lowerCasePath = path.toLowerCase(Locale.ROOT); + if (lowerCasePath.endsWith("db/variables.hjson")) { + return ConfigFile.ConfigFileType.VARIABLE; + } else if (lowerCasePath.endsWith("models/variables.hjson")) { + return ConfigFile.ConfigFileType.VARIABLE; + } else if (lowerCasePath.equals("models/security.hjson")) { + return ConfigFile.ConfigFileType.SECURITY; + } else if (DB_FILE.matcher(lowerCasePath).matches()) { + return ConfigFile.ConfigFileType.DATABASE; + } else if (TABLE_FILE.matcher(lowerCasePath).matches()) { + return ConfigFile.ConfigFileType.TABLE; + } else if (NAME_SPACE_FILE.matcher(lowerCasePath).matches()) { + return ConfigFile.ConfigFileType.NAMESPACE; + } else { + return ConfigFile.ConfigFileType.UNKNOWN; + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideArgumentNameFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideArgumentNameFormatAttr.java new file mode 100644 index 0000000000..7e4ff4c2a6 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideArgumentNameFormatAttr.java @@ -0,0 +1,47 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +/** + * Format specifier for {@code elideArgumentName} format attribute. + *

+ * This specifier will check if a string instance is a valid Elide Argument Name. + *

+ */ +public class ElideArgumentNameFormatAttr extends AbstractFormatAttribute { + + public static final String FORMAT_NAME = "elideArgumentName"; + public static final String NAME_KEY = "elideArgumentName.error.name"; + public static final String NAME_MSG = "Argument name [%s] is not allowed. Argument name cannot be 'grain'."; + public static final String FORMAT_KEY = "elideArgumentName.error.format"; + public static final String FORMAT_MSG = "Argument name [%s] is not allowed. Name must start with an alphabetic " + + "character and can include alaphabets, numbers and '_' only."; + + public ElideArgumentNameFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!ElideNameFormatAttr.NAME_FORMAT_REGEX.matcher(input).matches()) { + report.error(newMsg(data, bundle, FORMAT_KEY).putArgument("value", input)); + } + + if (input.equalsIgnoreCase("grain")) { + report.error(newMsg(data, bundle, NAME_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideCardinalityFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideCardinalityFormatAttr.java new file mode 100644 index 0000000000..13343e39d7 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideCardinalityFormatAttr.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideCardiality} format attribute. + *

+ * This specifier will check if a string instance is one of {@code Tiny, Small, Medium, Large, Huge}. + *

+ */ +public class ElideCardinalityFormatAttr extends AbstractFormatAttribute { + private static final Pattern CARDINALITY_PATTERN = Pattern.compile("^(?i)(Tiny|Small|Medium|Large|Huge)$"); + + public static final String FORMAT_NAME = "elideCardiality"; + public static final String TYPE_KEY = "elideCardiality.error.enum"; + public static final String TYPE_MSG = "Cardinality type [%s] is not allowed. Supported value is one of " + + "[Tiny, Small, Medium, Large, Huge]."; + + public ElideCardinalityFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!CARDINALITY_PATTERN.matcher(input).matches()) { + report.error(newMsg(data, bundle, TYPE_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldNameFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldNameFormatAttr.java new file mode 100644 index 0000000000..6c98c0eb9b --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldNameFormatAttr.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.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideFieldName} format attribute. + *

+ * This specifier will check if a string instance is a valid Elide Field Name. + *

+ */ +public class ElideFieldNameFormatAttr extends AbstractFormatAttribute { + private static final Pattern FIELD_NAME_FORMAT_REGEX = Pattern.compile("^[a-z][0-9A-Za-z_]*$"); + private static final Set RESERVED_KEYWORDS_FOR_COLUMN_NAME = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + + static { + RESERVED_KEYWORDS_FOR_COLUMN_NAME.add("id"); + RESERVED_KEYWORDS_FOR_COLUMN_NAME.add("sql"); + } + + public static final String FORMAT_NAME = "elideFieldName"; + public static final String NAME_KEY = "elideFieldName.error.name"; + public static final String NAME_MSG = + "Field name [%s] is not allowed. Field name cannot be one of " + RESERVED_KEYWORDS_FOR_COLUMN_NAME; + public static final String FORMAT_KEY = "elideFieldName.error.format"; + public static final String FORMAT_MSG = "Field name [%s] is not allowed. Field name must start with " + + "lower case alphabet and can include alaphabets, numbers and '_' only."; + + public ElideFieldNameFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!FIELD_NAME_FORMAT_REGEX.matcher(input).matches()) { + report.error(newMsg(data, bundle, FORMAT_KEY).putArgument("value", input)); + } + + if (RESERVED_KEYWORDS_FOR_COLUMN_NAME.contains(input)) { + report.error(newMsg(data, bundle, NAME_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldTypeFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldTypeFormatAttr.java new file mode 100644 index 0000000000..4c7584dd35 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldTypeFormatAttr.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideFieldType} format attribute. + *

+ * This specifier will check if a string instance is one of {@code Integer, Decimal, Money, Text, Coordinate, Boolean}. + *

+ */ +public class ElideFieldTypeFormatAttr extends AbstractFormatAttribute { + public static final Pattern FIELD_TYPE_PATTERN = + Pattern.compile("^(?i)(Integer|Decimal|Money|Text|Coordinate|Boolean|Enum_Text|Enum_Ordinal)$"); + + public static final String FORMAT_NAME = "elideFieldType"; + public static final String TYPE_KEY = "elideFieldType.error.enum"; + public static final String TYPE_MSG = "Field type [%s] is not allowed. Supported value is one of " + + "[Integer, Decimal, Money, Text, Coordinate, Boolean, Enum_Text, Enum_Ordinal]."; + + public ElideFieldTypeFormatAttr() { + this(FORMAT_NAME); + } + + public ElideFieldTypeFormatAttr(String formatName) { + super(formatName, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!FIELD_TYPE_PATTERN.matcher(input).matches()) { + report.error(newMsg(data, bundle, TYPE_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideGrainTypeFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideGrainTypeFormatAttr.java new file mode 100644 index 0000000000..e3bb8215c8 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideGrainTypeFormatAttr.java @@ -0,0 +1,46 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideGrainType} format attribute. + *

+ * This specifier will check if a string instance is one of + * {@code Second, Minute, Hour, Day, IsoWeek, Week, Month, Quarter, Year}. + *

+ */ +public class ElideGrainTypeFormatAttr extends AbstractFormatAttribute { + private static final Pattern GRAIN_TYPE_PATTERN = + Pattern.compile("^(?i)(Second|Minute|Hour|Day|IsoWeek|Week|Month|Quarter|Year)$"); + + public static final String FORMAT_NAME = "elideGrainType"; + public static final String TYPE_KEY = "elideGrainType.error.enum"; + public static final String TYPE_MSG = "Grain type [%s] is not allowed. Supported value is one of " + + "[Second, Minute, Hour, Day, IsoWeek, Week, Month, Quarter, Year]."; + + public ElideGrainTypeFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!GRAIN_TYPE_PATTERN.matcher(input).matches()) { + report.error(newMsg(data, bundle, TYPE_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideJDBCUrlFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideJDBCUrlFormatAttr.java new file mode 100644 index 0000000000..9cb8275942 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideJDBCUrlFormatAttr.java @@ -0,0 +1,40 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +/** + * Format specifier for {@code elideJdbcUrl} format attribute. + *

+ * This specifier will check if a string instance is a valid JDBC url. + *

+ */ +public class ElideJDBCUrlFormatAttr extends AbstractFormatAttribute { + + public static final String FORMAT_NAME = "elideJdbcUrl"; + public static final String FORMAT_KEY = "elideJdbcUrl.error.format"; + public static final String FORMAT_MSG = "Input value [%s] is not a valid JDBC url, it must start with 'jdbc:'."; + + public ElideJDBCUrlFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!input.startsWith("jdbc:")) { + report.error(newMsg(data, bundle, FORMAT_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideJoinKindFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideJoinKindFormatAttr.java new file mode 100644 index 0000000000..a45212fdd4 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideJoinKindFormatAttr.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideJoinKind} format attribute. + *

+ * This specifier will check if a string instance is one of {@code ToOne, ToMany}. + *

+ */ +public class ElideJoinKindFormatAttr extends AbstractFormatAttribute { + private static final Pattern JOIN_KIND_PATTERN = Pattern.compile("^(?i)(ToOne|ToMany)$"); + + public static final String FORMAT_NAME = "elideJoinKind"; + public static final String TYPE_KEY = "elideJoinKind.error.enum"; + public static final String TYPE_MSG = "Join kind [%s] is not allowed. Supported value is one of [ToOne, ToMany]."; + + public ElideJoinKindFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!JOIN_KIND_PATTERN.matcher(input).matches()) { + report.error(newMsg(data, bundle, TYPE_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideJoinTypeFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideJoinTypeFormatAttr.java new file mode 100644 index 0000000000..0200e4ed40 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideJoinTypeFormatAttr.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideJoinType} format attribute. + *

+ * This specifier will check if a string instance is one of {@code left, inner, full, cross}. + *

+ */ +public class ElideJoinTypeFormatAttr extends AbstractFormatAttribute { + private static final Pattern JOIN_TYPE_PATTERN = Pattern.compile("^(?i)(left|inner|full|cross)$"); + + public static final String FORMAT_NAME = "elideJoinType"; + public static final String TYPE_KEY = "elideJoinType.error.enum"; + public static final String TYPE_MSG = + "Join type [%s] is not allowed. Supported value is one of [left, inner, full, cross]."; + + public ElideJoinTypeFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!JOIN_TYPE_PATTERN.matcher(input).matches()) { + report.error(newMsg(data, bundle, TYPE_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideNameFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideNameFormatAttr.java new file mode 100644 index 0000000000..5d60adb495 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideNameFormatAttr.java @@ -0,0 +1,45 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideName} format attribute. + *

+ * This specifier will check if a string instance is a valid Elide Name. + *

+ */ +public class ElideNameFormatAttr extends AbstractFormatAttribute { + public static final Pattern NAME_FORMAT_REGEX = Pattern.compile("^[A-Za-z][0-9A-Za-z_]*$"); + + public static final String FORMAT_NAME = "elideName"; + public static final String FORMAT_KEY = "elideName.error.format"; + public static final String FORMAT_MSG = + "Name [%s] is not allowed. Name must start with an alphabetic character and can include " + + "alaphabets, numbers and '_' only."; + + public ElideNameFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!NAME_FORMAT_REGEX.matcher(input).matches()) { + report.error(newMsg(data, bundle, FORMAT_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideNamespaceNameFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideNamespaceNameFormatAttr.java new file mode 100644 index 0000000000..6ecfc0a84a --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideNamespaceNameFormatAttr.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.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideNamespaceName} format attribute. + *

+ * This specifier will check if a string instance is a valid Elide Name. + *

+ */ +public class ElideNamespaceNameFormatAttr extends AbstractFormatAttribute { + public static final Pattern NAME_FORMAT_REGEX = ElideNameFormatAttr.NAME_FORMAT_REGEX; + + public static final String FORMAT_NAME = "elideNamespaceName"; + public static final String FORMAT_KEY = "elideNamespaceName.error.format"; + public static final String FORMAT_ADDITIONAL_KEY = "elideNamespaceName.error.additional"; + public static final String FORMAT_KEY_MSG = + "Name [%s] is not allowed. Name must start with an alphabetic character and can include " + + "alaphabets, numbers and '_' only."; + public static final String FORMAT_ADDITIONAL_KEY_MSG = + "Name [%s] clashes with the 'default' namespace. Either change the case or pick a different namespace " + + "name."; + public static final String DEFAULT_NAME = "default"; + + public ElideNamespaceNameFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!NAME_FORMAT_REGEX.matcher(input).matches()) { + report.error(newMsg(data, bundle, FORMAT_KEY).putArgument("value", input)); + } + + if (!input.equals(DEFAULT_NAME) && input.toLowerCase(Locale.ENGLISH).equals(DEFAULT_NAME)) { + report.error(newMsg(data, bundle, FORMAT_ADDITIONAL_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideRSQLFilterFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideRSQLFilterFormatAttr.java new file mode 100644 index 0000000000..f9f586b4f7 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideRSQLFilterFormatAttr.java @@ -0,0 +1,46 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import static com.yahoo.elide.core.filter.dialect.RSQLFilterDialect.getDefaultOperatorsWithIsnull; +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; +import cz.jirutka.rsql.parser.RSQLParser; +import cz.jirutka.rsql.parser.RSQLParserException; + +/** + * Format specifier for {@code elideRSQLFilter} format attribute. + *

+ * This specifier will check if a string instance is a valid RSQL filter. + *

+ */ +public class ElideRSQLFilterFormatAttr extends AbstractFormatAttribute { + + public static final String FORMAT_NAME = "elideRSQLFilter"; + public static final String FORMAT_KEY = "elideRSQLFilter.error.format"; + public static final String FORMAT_MSG = "Input value[%s] is not a valid RSQL filter expression. Please visit page " + + "https://elide.io/pages/guide/v5/11-graphql.html#operators for samples."; + + public ElideRSQLFilterFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + try { + new RSQLParser(getDefaultOperatorsWithIsnull()).parse(input); + } catch (RSQLParserException e) { + report.error(newMsg(data, bundle, FORMAT_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideRoleFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideRoleFormatAttr.java new file mode 100644 index 0000000000..e030391f22 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideRoleFormatAttr.java @@ -0,0 +1,45 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideRole} format attribute. + *

+ * This specifier will check if a string instance is a valid Elide role. + *

+ */ +public class ElideRoleFormatAttr extends AbstractFormatAttribute { + private static final Pattern ROLE_FORMAT_REGEX = Pattern.compile("^[A-Za-z][0-9A-Za-z. ]*$"); + + public static final String FORMAT_NAME = "elideRole"; + public static final String FORMAT_KEY = "elideRole.error.format"; + public static final String FORMAT_MSG = + "Role [%s] is not allowed. Role must start with an alphabetic character and can include " + + "alaphabets, numbers, spaces and '.' only."; + + public ElideRoleFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!ROLE_FORMAT_REGEX.matcher(input).matches()) { + report.error(newMsg(data, bundle, FORMAT_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideTimeFieldTypeFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideTimeFieldTypeFormatAttr.java new file mode 100644 index 0000000000..6558d09ae5 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideTimeFieldTypeFormatAttr.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code elideTimeFieldType} format attribute. + *

+ * This specifier will check if a string instance is {@code Time}. + *

+ */ +public class ElideTimeFieldTypeFormatAttr extends AbstractFormatAttribute { + private static final Pattern TIME_FIELD_TYPE_PATTERN = Pattern.compile("^(?i)(Time)$"); + + public static final String FORMAT_NAME = "elideTimeFieldType"; + public static final String TYPE_KEY = "elideTimeFieldType.error.enum"; + public static final String TYPE_MSG = "Field type [%s] is not allowed. Field type must be " + + "[Time] for any time dimension."; + + public ElideTimeFieldTypeFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!TIME_FIELD_TYPE_PATTERN.matcher(input).matches()) { + report.error(newMsg(data, bundle, TYPE_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameFormatAttr.java new file mode 100644 index 0000000000..268186d356 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameFormatAttr.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code javaClassName} format attribute. + *

+ * This specifier will check if a string instance is a valid JAVA Class Name. + *

+ */ +public class JavaClassNameFormatAttr extends AbstractFormatAttribute { + private static final String ID_PATTERN = "\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*"; + public static final Pattern CLASS_NAME_FORMAT_PATTERN = Pattern.compile(ID_PATTERN + "(\\." + ID_PATTERN + ")*"); + + public static final String FORMAT_NAME = "javaClassName"; + public static final String FORMAT_KEY = "javaClassName.error.format"; + public static final String FORMAT_MSG = "Input value[%s] is not a valid Java class name."; + + public JavaClassNameFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!CLASS_NAME_FORMAT_PATTERN.matcher(input).matches()) { + report.error(newMsg(data, bundle, FORMAT_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameWithExtFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameWithExtFormatAttr.java new file mode 100644 index 0000000000..d31255dfe5 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameWithExtFormatAttr.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.github.fge.jackson.NodeType; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.format.AbstractFormatAttribute; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; + +import java.util.regex.Pattern; + +/** + * Format specifier for {@code javaClassNameWithExt} format attribute. + *

+ * This specifier will check if a string instance is a valid JAVA Class Name with {@code .class} extension. + *

+ */ +public class JavaClassNameWithExtFormatAttr extends AbstractFormatAttribute { + private static final Pattern CLASS_NAME_FORMAT_PATTERN = + Pattern.compile("^(\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*\\.)+class$"); + + public static final String FORMAT_NAME = "javaClassNameWithExt"; + public static final String FORMAT_KEY = "javaClassNameWithExt.error.format"; + public static final String FORMAT_MSG = "Input value[%s] is not a valid Java class name with .class extension."; + + public JavaClassNameWithExtFormatAttr() { + super(FORMAT_NAME, NodeType.STRING); + } + + @Override + public void validate(final ProcessingReport report, final MessageBundle bundle, final FullData data) + throws ProcessingException { + final String input = data.getInstance().getNode().textValue(); + + if (!CLASS_NAME_FORMAT_PATTERN.matcher(input).matches()) { + report.error(newMsg(data, bundle, FORMAT_KEY).putArgument("value", input)); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateArgsPropertiesKeyword.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateArgsPropertiesKeyword.java new file mode 100644 index 0000000000..f4bce95ab6 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateArgsPropertiesKeyword.java @@ -0,0 +1,73 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.fge.jackson.NodeType; +import com.github.fge.jackson.jsonpointer.JsonPointer; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.keyword.syntax.checkers.AbstractSyntaxChecker; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.core.tree.SchemaTree; +import com.github.fge.jsonschema.keyword.digest.AbstractDigester; +import com.github.fge.jsonschema.library.Keyword; +import com.github.fge.msgsimple.bundle.MessageBundle; +import lombok.Getter; + +import java.util.Collection; + +/** + * Creates custom keyword for {@code validateArgumentProperties}. + */ +public class ValidateArgsPropertiesKeyword { + + @Getter + private Keyword keyword; + + public ValidateArgsPropertiesKeyword() { + keyword = Keyword.newBuilder(ValidateArgsPropertiesValidator.KEYWORD) + .withSyntaxChecker(new ValidateArgsPropertiesSyntaxChecker()) + .withDigester(new ValidateArgsPropertiesDigester()) + .withValidatorClass(ValidateArgsPropertiesValidator.class) + .freeze(); + } + + /** + * Defines custom SyntaxChecker for {@code validateArgumentProperties}. + */ + private class ValidateArgsPropertiesSyntaxChecker extends AbstractSyntaxChecker { + + public ValidateArgsPropertiesSyntaxChecker() { + super(ValidateArgsPropertiesValidator.KEYWORD, NodeType.BOOLEAN); + } + + @Override + protected void checkValue(Collection pointers, MessageBundle bundle, ProcessingReport report, + SchemaTree tree) throws ProcessingException { + // AbstractSyntaxChecker has already verified that value is of type Boolean + // No additional Checks Required + } + } + + /** + * Defines custom Digester for {@code validateArgumentProperties}. + */ + private class ValidateArgsPropertiesDigester extends AbstractDigester { + + public ValidateArgsPropertiesDigester() { + super(ValidateArgsPropertiesValidator.KEYWORD, NodeType.OBJECT); + } + + @Override + public JsonNode digest(final JsonNode schema) { + final ObjectNode node = FACTORY.objectNode(); + node.put(keyword, schema.get(keyword).asBoolean(true)); + + return node; + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateArgsPropertiesValidator.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateArgsPropertiesValidator.java new file mode 100644 index 0000000000..98bbbc6750 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateArgsPropertiesValidator.java @@ -0,0 +1,58 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.processing.Processor; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.keyword.validator.AbstractKeywordValidator; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; +import com.google.common.collect.Sets; + +import java.util.Set; + +/** + * Defines custom Keyword Validator for {@code validateArgumentProperties}. + *

+ * This validator checks not both {@code tableSource} and {@code values} property is defined for any argument. + *

+ */ +public class ValidateArgsPropertiesValidator extends AbstractKeywordValidator { + + public static final String KEYWORD = "validateArgumentProperties"; + public static final String ATMOST_ONE_KEY = "validateArgumentProperties.error.atmostOne"; + public static final String ATMOST_ONE_MSG = + "tableSource and values cannot both be defined for an argument. Choose One or None."; + + private boolean validate; + + public ValidateArgsPropertiesValidator(final JsonNode digest) { + super(KEYWORD); + validate = digest.get(keyword).booleanValue(); + } + + @Override + public void validate(Processor processor, ProcessingReport report, MessageBundle bundle, + FullData data) throws ProcessingException { + + if (validate) { + JsonNode instance = data.getInstance().getNode(); + Set fields = Sets.newHashSet(instance.fieldNames()); + + if (fields.contains("values") && fields.contains("tableSource")) { + report.error(newMsg(data, bundle, ATMOST_ONE_KEY)); + } + + } + } + + @Override + public String toString() { + return keyword; + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateDimPropertiesKeyword.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateDimPropertiesKeyword.java new file mode 100644 index 0000000000..6b4bda6da9 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateDimPropertiesKeyword.java @@ -0,0 +1,72 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.fge.jackson.NodeType; +import com.github.fge.jackson.jsonpointer.JsonPointer; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.keyword.syntax.checkers.AbstractSyntaxChecker; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.core.tree.SchemaTree; +import com.github.fge.jsonschema.keyword.digest.AbstractDigester; +import com.github.fge.jsonschema.library.Keyword; +import com.github.fge.msgsimple.bundle.MessageBundle; +import lombok.Getter; + +import java.util.Collection; + +/** + * Creates custom keyword for {@code validateDimensionProperties}. + */ +public class ValidateDimPropertiesKeyword { + + @Getter + private Keyword keyword; + + public ValidateDimPropertiesKeyword() { + keyword = Keyword.newBuilder(ValidateDimPropertiesValidator.KEYWORD) + .withSyntaxChecker(new ValidateDimPropertiesSyntaxChecker()) + .withDigester(new ValidateDimPropertiesDigester()) + .withValidatorClass(ValidateDimPropertiesValidator.class) + .freeze(); + } + + /** + * Defines custom SyntaxChecker for {@code validateDimensionProperties}. + */ + private class ValidateDimPropertiesSyntaxChecker extends AbstractSyntaxChecker { + + public ValidateDimPropertiesSyntaxChecker() { + super(ValidateDimPropertiesValidator.KEYWORD, NodeType.BOOLEAN); + } + + @Override + protected void checkValue(Collection pointers, MessageBundle bundle, ProcessingReport report, + SchemaTree tree) throws ProcessingException { + // AbstractSyntaxChecker has already verified that value is of type Boolean + // No additional Checks Required + } + } + + /** + * Defines custom Digester for {@code validateDimensionProperties}. + */ + private class ValidateDimPropertiesDigester extends AbstractDigester { + + public ValidateDimPropertiesDigester() { + super(ValidateDimPropertiesValidator.KEYWORD, NodeType.OBJECT); + } + + @Override + public JsonNode digest(final JsonNode schema) { + final ObjectNode node = FACTORY.objectNode(); + node.put(keyword, schema.get(keyword).asBoolean(true)); + return node; + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateDimPropertiesValidator.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateDimPropertiesValidator.java new file mode 100644 index 0000000000..718a1430f1 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateDimPropertiesValidator.java @@ -0,0 +1,72 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.processing.Processor; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.keyword.validator.AbstractKeywordValidator; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; + +import java.util.Set; + +/** + * Defines custom Keyword Validator for {@code validateDimensionProperties}. + *

+ * This validator checks neither additional properties are defined for any dimension nor not both {@code tableSource} + * and {@code values} property is defined for any dimension. + *

+ */ +public class ValidateDimPropertiesValidator extends AbstractKeywordValidator { + + public static final Set COMMON_DIM_PROPERTIES = ImmutableSet.of("name", "friendlyName", + "description", "category", "hidden", "readAccess", "definition", "cardinality", "tags", "type", + "arguments", "filterTemplate"); + private static final Set ADDITIONAL_DIM_PROPERTIES = ImmutableSet.of("values", "tableSource"); + + public static final String KEYWORD = "validateDimensionProperties"; + public static final String ATMOST_ONE_KEY = "validateDimensionProperties.error.atmostOne"; + public static final String ATMOST_ONE_MSG = + "tableSource and values cannot both be defined for a dimension. Choose One or None."; + public static final String ADDITIONAL_KEY = "validateDimensionProperties.error.addtional"; + public static final String ADDITIONAL_MSG = "Properties: %s are not allowed for dimensions."; + + private boolean validate; + + public ValidateDimPropertiesValidator(final JsonNode digest) { + super(KEYWORD); + validate = digest.get(keyword).booleanValue(); + } + + @Override + public void validate(Processor processor, ProcessingReport report, MessageBundle bundle, + FullData data) throws ProcessingException { + + if (validate) { + JsonNode instance = data.getInstance().getNode(); + Set fields = Sets.newHashSet(instance.fieldNames()); + + if (fields.contains("values") && fields.contains("tableSource")) { + report.error(newMsg(data, bundle, ATMOST_ONE_KEY)); + } + + fields.removeAll(COMMON_DIM_PROPERTIES); + fields.removeAll(ADDITIONAL_DIM_PROPERTIES); + if (!fields.isEmpty()) { + report.error(newMsg(data, bundle, ADDITIONAL_KEY).putArgument("value", fields.toString())); + } + } + } + + @Override + public String toString() { + return keyword; + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateTimeDimPropertiesKeyword.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateTimeDimPropertiesKeyword.java new file mode 100644 index 0000000000..b985843e1f --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateTimeDimPropertiesKeyword.java @@ -0,0 +1,72 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.fge.jackson.NodeType; +import com.github.fge.jackson.jsonpointer.JsonPointer; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.keyword.syntax.checkers.AbstractSyntaxChecker; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.core.tree.SchemaTree; +import com.github.fge.jsonschema.keyword.digest.AbstractDigester; +import com.github.fge.jsonschema.library.Keyword; +import com.github.fge.msgsimple.bundle.MessageBundle; +import lombok.Getter; + +import java.util.Collection; + +/** + * Creates custom keyword for {@code validateTimeDimensionProperties}. + */ +public class ValidateTimeDimPropertiesKeyword { + + @Getter + private Keyword keyword; + + public ValidateTimeDimPropertiesKeyword() { + keyword = Keyword.newBuilder(ValidateTimeDimPropertiesValidator.KEYWORD) + .withSyntaxChecker(new ValidateTimeDimPropertiesSyntaxChecker()) + .withDigester(new ValidateTimeDimPropertiesDigester()) + .withValidatorClass(ValidateTimeDimPropertiesValidator.class) + .freeze(); + } + + /** + * Defines custom SyntaxChecker for {@code validateTimeDimensionProperties}. + */ + private class ValidateTimeDimPropertiesSyntaxChecker extends AbstractSyntaxChecker { + + public ValidateTimeDimPropertiesSyntaxChecker() { + super(ValidateTimeDimPropertiesValidator.KEYWORD, NodeType.BOOLEAN); + } + + @Override + protected void checkValue(Collection pointers, MessageBundle bundle, ProcessingReport report, + SchemaTree tree) throws ProcessingException { + // AbstractSyntaxChecker has already verified that value is of type Boolean + // No additional Checks Required + } + } + + /** + * Defines custom Digester for {@code validateTimeDimensionProperties}. + */ + private class ValidateTimeDimPropertiesDigester extends AbstractDigester { + + public ValidateTimeDimPropertiesDigester() { + super(ValidateTimeDimPropertiesValidator.KEYWORD, NodeType.OBJECT); + } + + @Override + public JsonNode digest(final JsonNode schema) { + final ObjectNode node = FACTORY.objectNode(); + node.put(keyword, schema.get(keyword).asBoolean(true)); + return node; + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateTimeDimPropertiesValidator.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateTimeDimPropertiesValidator.java new file mode 100644 index 0000000000..c99202b847 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ValidateTimeDimPropertiesValidator.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.jsonformats; + +import static com.yahoo.elide.modelconfig.jsonformats.ValidateDimPropertiesValidator.COMMON_DIM_PROPERTIES; +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.jsonschema.core.exceptions.ProcessingException; +import com.github.fge.jsonschema.core.processing.Processor; +import com.github.fge.jsonschema.core.report.ProcessingReport; +import com.github.fge.jsonschema.keyword.validator.AbstractKeywordValidator; +import com.github.fge.jsonschema.processors.data.FullData; +import com.github.fge.msgsimple.bundle.MessageBundle; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; + +import java.util.Set; + +/** + * Defines custom Keyword Validator for {@code validateTimeDimensionProperties}. + *

+ * This validator checks no additional properties are defined for any time dimension. + *

+ */ +public class ValidateTimeDimPropertiesValidator extends AbstractKeywordValidator { + + private static final Set ADDITIONAL_TIME_DIM_PROPERTIES = ImmutableSet.of("grains"); + + public static final String KEYWORD = "validateTimeDimensionProperties"; + public static final String ADDITIONAL_KEY = "validateTimeDimensionProperties.error.addtional"; + public static final String ADDITIONAL_MSG = "Properties: %s are not allowed for time dimensions."; + + private boolean validate; + + public ValidateTimeDimPropertiesValidator(final JsonNode digest) { + super(KEYWORD); + validate = digest.get(keyword).booleanValue(); + } + + @Override + public void validate(Processor processor, ProcessingReport report, MessageBundle bundle, + FullData data) throws ProcessingException { + + if (validate) { + JsonNode instance = data.getInstance().getNode(); + Set fields = Sets.newHashSet(instance.fieldNames()); + + fields.removeAll(COMMON_DIM_PROPERTIES); + fields.removeAll(ADDITIONAL_TIME_DIM_PROPERTIES); + if (!fields.isEmpty()) { + report.error(newMsg(data, bundle, ADDITIONAL_KEY).putArgument("value", fields.toString())); + } + } + } + + @Override + public String toString() { + return keyword; + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Argument.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Argument.java new file mode 100644 index 0000000000..9416c753a0 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Argument.java @@ -0,0 +1,67 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.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.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Argument Model. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "name", + "description", + "type", + "values", + "tableSource", + "default" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class Argument implements Named { + + @JsonProperty("name") + private String name; + + @JsonProperty("description") + private String description; + + @JsonProperty("type") + private String type; + + @JsonProperty("values") + @JsonDeserialize(as = LinkedHashSet.class) + private Set values = new LinkedHashSet<>(); + + @JsonProperty("tableSource") + private TableSource tableSource; + + @JsonProperty("default") + private Object defaultValue; + + /** + * Returns description of the argument. + * If null, returns the name + * @return description + */ + public String getDescription() { + return (this.description == null ? getName() : this.description); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/DBConfig.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/DBConfig.java new file mode 100644 index 0000000000..1d04b1c999 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/DBConfig.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.modelconfig.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.HashMap; +import java.util.Map; + +/** + * DB Config JSON. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "name", + "url", + "driver", + "user", + "dialect", + "propertyMap" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class DBConfig implements Named { + + @JsonProperty("name") + private String name; + + @JsonProperty("url") + private String url; + + @JsonProperty("driver") + private String driver; + + @JsonProperty("user") + private String user; + + @JsonProperty("dialect") + private String dialect; + + @JsonProperty("propertyMap") + @JsonDeserialize(as = HashMap.class) + private Map propertyMap = new HashMap<>(); +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Dimension.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Dimension.java new file mode 100644 index 0000000000..0b801d31c4 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Dimension.java @@ -0,0 +1,121 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.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.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.Singular; + +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", + "friendlyName", + "description", + "category", + "hidden", + "readAccess", + "definition", + "cardinality", + "type", + "grains", + "tags", + "arguments", + "values", + "tableSource", + "filterTemplate" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class Dimension implements Named { + + @JsonProperty("name") + private String name; + + @JsonProperty("friendlyName") + private String friendlyName; + + @JsonProperty("description") + private String description; + + @JsonProperty("category") + private String category; + + @JsonProperty("hidden") + @Builder.Default + private Boolean hidden = false; + + @JsonProperty("readAccess") + @Builder.Default + private String readAccess = "Prefab.Role.All"; + + @JsonProperty("definition") + private String definition; + + @JsonProperty("cardinality") + private String cardinality; + + @JsonProperty("type") + private String type; + + @JsonProperty("grains") + @Singular + private List grains = new ArrayList<>(); + + @JsonProperty("tags") + @JsonDeserialize(as = LinkedHashSet.class) + private Set tags = new LinkedHashSet<>(); + + @JsonProperty("arguments") + @Singular + private List arguments = new ArrayList<>(); + + @JsonProperty("values") + @JsonDeserialize(as = LinkedHashSet.class) + private Set values = new LinkedHashSet<>(); + + @JsonProperty("tableSource") + private TableSource tableSource; + + @JsonProperty("filterTemplate") + private String filterTemplate; + + /** + * Returns description of the dimension. + * If null, returns the name. + * @return description + */ + public String getDescription() { + return (this.description == null ? getName() : this.description); + } + + /** + * Checks if this dimension has provided argument. + * @param argName Name of the {@link Argument} to check for. + * @return true if this dimension has provided argument. + */ + public boolean hasArgument(String argName) { + return hasName(this.arguments, argName); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideDBConfig.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideDBConfig.java new file mode 100644 index 0000000000..9ab5863076 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideDBConfig.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.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 DB POJO. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "dbconfigs" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class ElideDBConfig { + + @JsonProperty("dbconfigs") + @JsonDeserialize(as = LinkedHashSet.class) + private Set dbconfigs = new LinkedHashSet<>(); +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideNamespaceConfig.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideNamespaceConfig.java new file mode 100644 index 0000000000..ffa623df6b --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideNamespaceConfig.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.modelconfig.model; + +import static com.yahoo.elide.modelconfig.model.NamespaceConfig.DEFAULT; +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.Locale; +import java.util.Set; + +/** + * Elide Namespace POJO. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "namespaces" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +public class ElideNamespaceConfig { + + @JsonProperty("namespaces") + @JsonDeserialize(as = LinkedHashSet.class) + private Set namespaceconfigs = new LinkedHashSet<>(); + + /** + * Checks if a namespace exists with the given name. + * @param name namespace Name + * @return true if a dynamic namespace exists with the given name. + */ + public boolean hasNamespace(String name, String version) { + String nameLower = name.toLowerCase(Locale.ENGLISH); + if (nameLower.equals(DEFAULT)) { + return true; + } + + return namespaceconfigs + .stream() + .filter(namespace -> namespace.getApiVersion().equals(version)) + .map(Named::getName) + .anyMatch(name::equals); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideSQLDBConfig.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideSQLDBConfig.java new file mode 100644 index 0000000000..3860b55d0b --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideSQLDBConfig.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.modelconfig.model; + +/** + * Elide SQL DB POJO. + */ +public class ElideSQLDBConfig extends ElideDBConfig { + +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideSecurityConfig.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideSecurityConfig.java new file mode 100644 index 0000000000..69699385e0 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideSecurityConfig.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.modelconfig.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<>(); + + public boolean hasCheckDefined(String role) { + + return roles + .stream() + .anyMatch(role::equals); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideTableConfig.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideTableConfig.java new file mode 100644 index 0000000000..1dbd73a0a5 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/ElideTableConfig.java @@ -0,0 +1,61 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.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<>(); + + /** + * Checks if a dynamic model exists with the given name. + * @param name Model Name + * @return true if a dynamic model exists with the given name. + */ + public boolean hasTable(String name) { + return tables + .stream() + .map(Named::getGlobalName) + .anyMatch(name::equals); + } + + /** + * Provides the dynamic model with the given name. + * @param name Model Name + * @return dynamic model with the given name. + */ + public Table getTable(String name) { + return tables + .stream() + .filter(t -> t.getGlobalName().equals(name)) + .findFirst() + .orElse(null); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Grain.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Grain.java new file mode 100644 index 0000000000..0b9d821a33 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Grain.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.modelconfig.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Grain can have SQL expressions that can substitute column + * with the dimension definition expression. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "type", + "sql" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class Grain { + + + @JsonProperty("type") + private Grain.GrainType type; + + @JsonProperty("sql") + private String sql; + + public enum GrainType { + + DAY("DAY"), + HOUR("HOUR"), + ISOWEEK("ISOWEEK"), + MINUTE("MINUTE"), + MONTH("MONTH"), + QUARTER("QUARTER"), + SECOND("SECOND"), + WEEK("WEEK"), + YEAR("YEAR"); + + private final String value; + + private GrainType(String value) { + this.value = value; + } + + @Override + public String toString() { + return this.value; + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Join.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Join.java new file mode 100644 index 0000000000..55627f1c73 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Join.java @@ -0,0 +1,103 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.model; + +import static com.yahoo.elide.modelconfig.model.NamespaceConfig.DEFAULT; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * 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", + "namespace", + "to", + "type", + "kind", + "definition" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class Join implements Named { + + @JsonProperty("name") + private String name; + + @JsonProperty("namespace") + private String namespace = DEFAULT; + + @JsonProperty("to") + private String to; + + @JsonProperty("type") + private Join.Type type; + + @JsonProperty("kind") + private Join.Kind kind = Join.Kind.TOONE; + + @JsonProperty("definition") + private String definition; + + /** + * Returns the destination table of the join. + * @return The global name of the destination join table. + */ + public String getTo() { + if (namespace == null || namespace.isEmpty() || namespace.equals(DEFAULT)) { + return to; + } + + return namespace + "_" + to; + } + + public enum Kind { + + TOONE("toOne"), + TOMANY("toMany"); + + private final String value; + + private Kind(String value) { + this.value = value; + } + + @Override + public String toString() { + return this.value; + } + } + + public enum Type { + + LEFT("left"), + INNER("inner"), + FULL("full"), + CROSS("cross"); + + private final String value; + + private Type(String value) { + this.value = value; + } + + @Override + public String toString() { + return this.value; + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Measure.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Measure.java new file mode 100644 index 0000000000..046c161dab --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Measure.java @@ -0,0 +1,106 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.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.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.Singular; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Measures represent metrics that can be aggregated at query time. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "name", + "friendlyName", + "description", + "category", + "hidden", + "readAccess", + "definition", + "maker", + "type", + "tags", + "arguments", + "filterTemplate" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class Measure implements Named { + + @JsonProperty("name") + private String name; + + @JsonProperty("friendlyName") + private String friendlyName; + + @JsonProperty("description") + private String description; + + @JsonProperty("category") + private String category; + + @JsonProperty("hidden") + @Builder.Default + private Boolean hidden = false; + + @JsonProperty("readAccess") + @Builder.Default + private String readAccess = "Prefab.Role.All"; + + @JsonProperty("definition") + private String definition; + + @JsonProperty("type") + private String type; + + @JsonProperty("maker") + private String maker; + + @JsonProperty("tags") + @JsonDeserialize(as = LinkedHashSet.class) + private Set tags = new LinkedHashSet<>(); + + @JsonProperty("arguments") + @Singular + private List arguments = new ArrayList<>(); + + @JsonProperty("filterTemplate") + private String filterTemplate; + + /** + * Returns description of the measure. + * If null, returns the name. + * @return description + */ + public String getDescription() { + return (this.description == null ? getName() : this.description); + } + + /** + * Checks if this measure has provided argument. + * @param argName Name of the {@link Argument} to check for. + * @return true if this measure has provided argument. + */ + public boolean hasArgument(String argName) { + return hasName(this.arguments, argName); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Named.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Named.java new file mode 100644 index 0000000000..a0022c5fbc --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Named.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.modelconfig.model; + +import java.util.Collection; + +public interface Named { + /** + * Get the name local to its parent. + * @return the local name + */ + String getName(); + + /** + * Get the globally unique name . + * @return the global name + */ + default String getGlobalName() { + return getName(); + } + + /** + * Checks if the collection has an object with given name. + * @param collection of object with name property + * @param name to search for in given collection + * @return true if the collection has an object with given name. + */ + default boolean hasName(Collection collection, String name) { + return collection + .stream() + .map(Named::getGlobalName) + .anyMatch(name::equals); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/NamespaceConfig.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/NamespaceConfig.java new file mode 100644 index 0000000000..d14358c8ca --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/NamespaceConfig.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.modelconfig.model; + +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Namespace Config JSON. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "name", + "friendlyName", + "readAccess", + "description", + "apiVersion" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class NamespaceConfig implements Named { + + public static String DEFAULT = "default"; + + @JsonProperty("name") + private String name; + + @JsonProperty("friendlyName") + private String friendlyName; + + @Builder.Default + @JsonProperty("readAccess") + private String readAccess = "Prefab.Role.All"; + + @JsonProperty("description") + private String description; + + @Builder.Default + @JsonProperty("apiVersion") + private String apiVersion = EntityDictionary.NO_VERSION; + + /** + * Returns description of the namespace object. + * If null, returns the name. + * @return description + */ + public String getDescription() { + return (this.description == null ? getName() : this.description); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Rule.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Rule.java new file mode 100644 index 0000000000..b05fea1512 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Rule.java @@ -0,0 +1,73 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.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-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Table.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Table.java new file mode 100644 index 0000000000..b7fd5ec2b4 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Table.java @@ -0,0 +1,239 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.model; + +import static com.yahoo.elide.modelconfig.model.NamespaceConfig.DEFAULT; +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 com.google.common.collect.Streams; +import org.apache.commons.lang3.StringUtils; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.Singular; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Table Model JSON. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "name", + "friendlyName", + "schema", + "isFact", + "hidden", + "description", + "cardinality", + "readAccess", + "namespace", + "joins", + "measures", + "dimensions", + "tags", + "hints", + "arguments", + "extend", + "sql", + "table", + "dbConnectionName", + "filterTemplate" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class Table implements Named { + @JsonProperty("name") + private String name; + + @JsonProperty("friendlyName") + private String friendlyName; + + @JsonProperty("schema") + private String schema; + + @JsonProperty("dbConnectionName") + private String dbConnectionName; + + @Builder.Default + @JsonProperty("isFact") + private Boolean isFact = true; + + @Builder.Default + @JsonProperty("hidden") + private Boolean hidden = false; + + @JsonProperty("description") + private String description; + + @JsonProperty("category") + private String category; + + @JsonProperty("filterTemplate") + private String filterTemplate; + + @JsonProperty("cardinality") + private String cardinality; + + @Builder.Default + @JsonProperty("readAccess") + private String readAccess = "Prefab.Role.All"; + + @Builder.Default + @JsonProperty("namespace") + private String namespace = DEFAULT; + + @JsonProperty("joins") + @Singular + private List joins = new ArrayList<>(); + + @JsonProperty("measures") + @Singular + private List measures = new ArrayList<>(); + + @JsonProperty("dimensions") + @Singular + private List dimensions = new ArrayList<>(); + + @Builder.Default + @JsonProperty("tags") + @JsonDeserialize(as = LinkedHashSet.class) + private Set tags = new LinkedHashSet<>(); + + @Builder.Default + @JsonProperty("hints") + @JsonDeserialize(as = LinkedHashSet.class) + private Set hints = new LinkedHashSet<>(); + + @JsonProperty("arguments") + @Singular + private List arguments = new ArrayList<>(); + + @JsonProperty("extend") + 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); + } + + /** + * Checks if this model has provided field. + * @param fieldName Name of the {@link Dimension} or {@link Measure} to check for. + * @return true if this model has provided field. + */ + public boolean hasField(String fieldName) { + return hasName(this.dimensions, fieldName) || hasName(this.measures, fieldName); + } + + /** + * Provides the Field details for provided field name. + * @param fieldName Name of {@link Dimension} or {@link Measure} to retrieve. + * @return Field for provided field name. + */ + public Named getField(String fieldName) { + return Streams.concat(this.dimensions.stream(), this.measures.stream()) + .filter(col -> col.getName().equals(fieldName)) + .findFirst() + .orElse(null); + } + + /** + * Checks if this model has provided argument. + * @param argName Name of the {@link Argument} to check for. + * @return true if this model has provided argument. + */ + public boolean hasArgument(String argName) { + return hasName(this.arguments, argName); + } + + /** + * Checks if this model has provided join field. + * @param joinName Name of the {@link Join} to check for. + * @return true if this model has provided join field. + */ + public boolean hasJoinField(String joinName) { + return hasName(this.joins, joinName); + } + + /** + * Provides the Join details for provided join name. + * @param joinName Name of the {@link Join} to retrieve. + * @return Join for provided join name. + */ + public Join getJoin(String joinName) { + return this.joins.stream() + .filter(join -> join.getName().equals(joinName)) + .findFirst() + .orElse(null); + } + + /** + * Checks if this model has a parent model. + * @return true if this model extends another model + */ + public boolean hasParent() { + return StringUtils.isNotBlank(this.extend); + } + + /** + * Provides the parent model for this model. + * @param elideTableConfig {@link ElideTableConfig} + * @return Parent model for this model + */ + public Table getParent(ElideTableConfig elideTableConfig) { + return elideTableConfig.getTable(getGlobalExtend()); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getGlobalName() { + return getModelName(name, namespace); + } + + /** + * Return the globally unique table name for the inherited table. + * @return The globally unique name for the inherited table or null. + */ + public String getGlobalExtend() { + if (extend == null || extend.isEmpty()) { + return extend; + } + return getModelName(extend, namespace); + } + + public static String getModelName(String tableName, String namespace) { + if (namespace == null || namespace.isEmpty() || namespace.equals(DEFAULT)) { + return tableName; + } + + return namespace + "_" + tableName; + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/TableSource.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/TableSource.java new file mode 100644 index 0000000000..c0500100c1 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/TableSource.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.modelconfig.model; + +import static com.yahoo.elide.modelconfig.model.NamespaceConfig.DEFAULT; +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.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * TableSource is a reference to another table's columns where the values for a dimension or argument can + * queried. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonPropertyOrder({ + "table", + "namespace", + "column", + "suggestionColumns" +}) +@Data +@EqualsAndHashCode() +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class TableSource { + + @JsonProperty("table") + private String table; + + @JsonProperty("namespace") + private String namespace = DEFAULT; + + @JsonProperty("column") + private String column; + + @JsonProperty("suggestionColumns") + @JsonDeserialize(as = LinkedHashSet.class) + private Set suggestionColumns = new LinkedHashSet<>(); +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Type.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Type.java new file mode 100644 index 0000000000..0ad2a7873a --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Type.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.modelconfig.model; + +/** + * Data Type of the field. + */ +public class Type { + public final static String TIME = "TIME"; + public final static String INTEGER = "INTEGER"; + public final static String DECIMAL = "DECIMAL"; + public final static String MONEY = "MONEY"; + public final static String TEXT = "TEXT"; + public final static String ENUM_ORDINAL = "ENUM_ORDINAL"; + public final static String ENUM_TEXT = "ENUM_TEXT"; + public final static String COORDINATE = "COORDINATE"; + public final static String BOOLEAN = "BOOLEAN"; +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydrator.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydrator.java new file mode 100644 index 0000000000..78debaf879 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydrator.java @@ -0,0 +1,53 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.parser.handlebars; + +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.io.ClassPathTemplateLoader; +import com.github.jknack.handlebars.io.TemplateLoader; + +import java.io.IOException; +import java.util.Map; + +/** + * Class for handlebars hydration. + */ +public class HandlebarsHydrator { + + 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[][]{ + {"\"", """ }, + {"`", "`" }, + {"\n", " " } + }); + + private final Handlebars handlebars; + + public HandlebarsHydrator() { + TemplateLoader loader = new ClassPathTemplateLoader("/templates"); + this.handlebars = new Handlebars(loader).with(MY_ESCAPING_STRATEGY); + } + + /** + * 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(); + Template template = handlebars.compileInline(config, HANDLEBAR_START_DELIMITER, HANDLEBAR_END_DELIMITER); + + return template.apply(context); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStore.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStore.java new file mode 100644 index 0000000000..f681055840 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStore.java @@ -0,0 +1,48 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store; + +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.modelconfig.io.FileLoader; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; +import com.yahoo.elide.modelconfig.validator.Validator; + +/** + * Elide DataStore which loads/persists HJSON configuration files as Elide models. + */ +public class ConfigDataStore implements DataStore { + + public static final String VALIDATE_ONLY_HEADER = "ValidateOnly"; + + private final FileLoader fileLoader; + private final Validator validator; + + public ConfigDataStore( + String configRoot, + Validator validator + ) { + this.fileLoader = new FileLoader(configRoot); + this.validator = validator; + } + + @Override + public void populateEntityDictionary(EntityDictionary dictionary) { + dictionary.bindEntity(ClassType.of(ConfigFile.class)); + } + + @Override + public ConfigDataStoreTransaction beginTransaction() { + return new ConfigDataStoreTransaction(fileLoader, false, validator); + } + + @Override + public ConfigDataStoreTransaction beginReadTransaction() { + return new ConfigDataStoreTransaction(fileLoader, true, validator); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTransaction.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTransaction.java new file mode 100644 index 0000000000..9a56c785a8 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTransaction.java @@ -0,0 +1,250 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store; + +import static com.yahoo.elide.modelconfig.store.ConfigDataStore.VALIDATE_ONLY_HEADER; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.modelconfig.io.FileLoader; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; +import com.yahoo.elide.modelconfig.validator.Validator; + +import org.apache.commons.io.FileUtils; + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * Elide DataStoreTransaction which loads/persists HJSON configuration files as Elide models. + */ +@Slf4j +public class ConfigDataStoreTransaction implements DataStoreTransaction { + private final FileLoader fileLoader; + private final Set todo; + private final Set dirty; + private final Set deleted; + private final Validator validator; + private final boolean readOnly; + + public ConfigDataStoreTransaction( + FileLoader fileLoader, + boolean readOnly, + Validator validator + ) { + this.fileLoader = fileLoader; + this.readOnly = readOnly || !fileLoader.isWriteable(); + this.dirty = new LinkedHashSet<>(); + this.deleted = new LinkedHashSet<>(); + this.todo = new LinkedHashSet<>(); + this.validator = validator; + } + + @Override + public void save(T entity, RequestScope scope) { + ConfigFile file = (ConfigFile) entity; + + boolean canWrite; + if (scope.isNewResource(file)) { + canWrite = canCreate(file.getPath()); + } else { + canWrite = canModify(file.getPath()); + } + + if (readOnly || !canWrite) { + log.error("Attempt to modify a read only configuration"); + throw new UnsupportedOperationException("Configuration is read only."); + } + + dirty.add(file); + todo.add(() -> updateFile(file.getPath(), file.getContent())); + } + + @Override + public void delete(T entity, RequestScope scope) { + ConfigFile file = (ConfigFile) entity; + if (readOnly || !canModify(file.getPath())) { + log.error("Attempt to modify a read only configuration"); + throw new UnsupportedOperationException("Configuration is read only."); + } + dirty.add(file); + deleted.add(file.getPath()); + todo.add(() -> deleteFile(file.getPath())); + } + + @Override + public void flush(RequestScope scope) { + if (!readOnly) { + Map resources; + try { + resources = fileLoader.loadResources(); + } catch (IOException e) { + log.error("Error reading configuration resources: {}", e.getMessage()); + throw new IllegalStateException(e); + } + + for (ConfigFile file : dirty) { + resources.put(file.getPath(), file); + } + + for (String path: deleted) { + resources.remove(path); + } + + try { + validator.validate(resources); + } catch (Exception e) { + log.error("Error validating configuration: {}", e.getMessage()); + throw new BadRequestException(e.getMessage()); + } + } + } + + @Override + public void commit(RequestScope scope) { + boolean validateOnly = scope.getRequestHeaderByName(VALIDATE_ONLY_HEADER) != null; + + if (! validateOnly) { + for (Runnable runnable : todo) { + runnable.run(); + } + } + } + + @Override + public void createObject(T entity, RequestScope scope) { + ConfigFile file = (ConfigFile) entity; + + if (readOnly || !canCreate(file.getPath())) { + log.error("Attempt to modify a read only configuration"); + throw new UnsupportedOperationException("Configuration is read only."); + } + dirty.add(file); + todo.add(() -> { + + //We have to assign the ID here during commit so it gets sent back in the response. + file.setId(ConfigFile.toId(file.getPath(), file.getVersion())); + + createFile(file.getPath()); + updateFile(file.getPath(), file.getContent()); + }); + } + + @Override + public T loadObject(EntityProjection entityProjection, Serializable id, RequestScope scope) { + String path = ConfigFile.fromId(id.toString()); + + try { + return (T) fileLoader.loadResource(path); + } catch (IOException e) { + log.error("Error reading configuration resources for {} : {}", id, e.getMessage()); + return null; + } + } + + @Override + public DataStoreIterable loadObjects(EntityProjection entityProjection, RequestScope scope) { + try { + Map resources = fileLoader.loadResources(); + + return new DataStoreIterableBuilder(resources.values()).allInMemory().build(); + } catch (IOException e) { + log.error("Error reading configuration resources: {}", e.getMessage()); + throw new IllegalStateException(e); + } + } + + @Override + public void cancel(RequestScope scope) { + todo.clear(); + dirty.clear(); + deleted.clear(); + } + + @Override + public void close() throws IOException { + //NOOP + } + + private boolean canCreate(String filePath) { + Path path = Path.of(fileLoader.getRootPath(), filePath); + File directory = path.toFile().getParentFile(); + + while (directory != null && !directory.exists()) { + directory = directory.getParentFile(); + } + + return (directory != null && Files.isWritable(directory.toPath())); + } + + private boolean canModify(String filePath) { + Path path = Path.of(fileLoader.getRootPath(), filePath); + File file = path.toFile(); + return !file.exists() || Files.isWritable(file.toPath()); + } + + private void deleteFile(String path) { + Path deletePath = Path.of(fileLoader.getRootPath(), path); + File file = deletePath.toFile(); + + if (! file.exists()) { + return; + } + + if (! file.delete()) { + log.error("Error deleting file: {}", file.getPath()); + throw new IllegalStateException("Unable to delete: " + path); + } + } + + private void updateFile(String path, String content) { + Path updatePath = Path.of(fileLoader.getRootPath(), path); + File file = updatePath.toFile(); + + try { + FileUtils.writeStringToFile(file, content, StandardCharsets.UTF_8); + } catch (IOException e) { + log.error("Error updating file: {} with message: {}", file.getPath(), e.getMessage()); + throw new IllegalStateException(e); + } + } + private void createFile(String path) { + Path createPath = Path.of(fileLoader.getRootPath(), path); + File file = createPath.toFile(); + + if (file.exists()) { + log.debug("File already exits: {}", file.getPath()); + return; + } + + try { + File parentDirectory = file.getParentFile(); + Files.createDirectories(Path.of(parentDirectory.getPath())); + + boolean created = file.createNewFile(); + if (!created) { + log.error("Unable to create file: {}", file.getPath()); + throw new IllegalStateException("Unable to create file: " + path); + } + } catch (IOException e) { + log.error("Error creating file: {} with message: {}", file.getPath(), e.getMessage()); + throw new IllegalStateException(e); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigChecks.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigChecks.java new file mode 100644 index 0000000000..ccff7f56e1 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigChecks.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store.models; + +import com.yahoo.elide.core.security.checks.prefab.Role; + +/** + * Utility class which contains a set of check labels. Clients need to define checks for these + * labels and bind them to the dictionary at boot. + */ +public class ConfigChecks { + public static final String CAN_READ_CONFIG = "Can Read Config"; + public static final String CAN_UPDATE_CONFIG = "Can Update Config"; + public static final String CAN_DELETE_CONFIG = "Can Delete Config"; + public static final String CAN_CREATE_CONFIG = "Can Create Config"; + + public static class CanNotRead extends Role.NONE { + + }; + public static class CanNotUpdate extends Role.NONE { + + }; + public static class CanNotCreate extends Role.NONE { + + }; + public static class CanNotDelete extends Role.NONE { + + }; + public static class CanRead extends Role.ALL { + + }; + public static class CanUpdate extends Role.ALL { + + }; + public static class CanCreate extends Role.ALL { + + }; + public static class CanDelete extends Role.ALL { + + }; +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigFile.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigFile.java new file mode 100644 index 0000000000..0aee3b77c0 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigFile.java @@ -0,0 +1,147 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store.models; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static com.yahoo.elide.core.security.checks.prefab.Role.NONE_ROLE; +import com.yahoo.elide.annotation.ComputedAttribute; +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 com.yahoo.elide.core.exceptions.BadRequestException; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.Base64; +import java.util.Objects; +import java.util.function.Supplier; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +/** + * Represents an HJSON configuration file for dynamic Elide models. + */ +@Include(name = "config") +@Data +@NoArgsConstructor +@ReadPermission(expression = ConfigChecks.CAN_READ_CONFIG) +@UpdatePermission(expression = ConfigChecks.CAN_UPDATE_CONFIG) +@DeletePermission(expression = ConfigChecks.CAN_DELETE_CONFIG) +@CreatePermission(expression = ConfigChecks.CAN_CREATE_CONFIG) +public class ConfigFile { + + public enum ConfigFileType { + NAMESPACE, + TABLE, + VARIABLE, + DATABASE, + SECURITY, + UNKNOWN; + } + + @Id + @GeneratedValue + private String id; //Base64 encoded path-version + + @UpdatePermission(expression = NONE_ROLE) + private String path; + + private String version; + + @Exclude + private Supplier contentProvider; + + @Exclude + private String content; + + @ComputedAttribute + public String getContent() { + if (content == null) { + if (contentProvider == null) { + return null; + } + content = contentProvider.get(); + } + + return content; + } + + private ConfigFileType type; + + @Builder + public ConfigFile( + String path, + String version, + ConfigFileType type, + Supplier contentProvider) { + + this.id = toId(path, version); + + this.path = path; + if (version == null) { + this.version = NO_VERSION; + } else { + this.version = version; + } + this.contentProvider = contentProvider; + this.type = type; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ConfigFile that = (ConfigFile) o; + return Objects.equals(path, that.path) && Objects.equals(version, that.version); + } + + @Override + public int hashCode() { + return Objects.hash(path, version); + } + + public static String toId(String path, String version) { + String id; + if (version == null || version.isEmpty()) { + id = path; + } else { + id = path + "-" + version; + } + return Base64.getEncoder().encodeToString(id.getBytes()); + } + + public static String fromId(String id) { + String idString; + try { + idString = URLDecoder.decode(id, "UTF-8"); + idString = new String(Base64.getDecoder().decode(idString.getBytes())); + } catch (IllegalArgumentException | UnsupportedEncodingException e) { + throw new BadRequestException("Invalid ID: " + id); + } + + int hyphenIndex = idString.lastIndexOf(".hjson-"); + + String path; + if (hyphenIndex < 0) { + path = idString; + } else { + path = idString.substring(0, idString.lastIndexOf('-')); + } + + return path; + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidator.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidator.java new file mode 100644 index 0000000000..96aa43d4fe --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidator.java @@ -0,0 +1,873 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.validator; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.SecurityCheck; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.EntityPermissions; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.security.checks.FilterExpressionCheck; +import com.yahoo.elide.core.security.checks.UserCheck; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.modelconfig.Config; +import com.yahoo.elide.modelconfig.DynamicConfigHelpers; +import com.yahoo.elide.modelconfig.DynamicConfigSchemaValidator; +import com.yahoo.elide.modelconfig.DynamicConfiguration; +import com.yahoo.elide.modelconfig.io.FileLoader; +import com.yahoo.elide.modelconfig.model.Argument; +import com.yahoo.elide.modelconfig.model.DBConfig; +import com.yahoo.elide.modelconfig.model.Dimension; +import com.yahoo.elide.modelconfig.model.ElideDBConfig; +import com.yahoo.elide.modelconfig.model.ElideNamespaceConfig; +import com.yahoo.elide.modelconfig.model.ElideSQLDBConfig; +import com.yahoo.elide.modelconfig.model.ElideSecurityConfig; +import com.yahoo.elide.modelconfig.model.ElideTableConfig; +import com.yahoo.elide.modelconfig.model.Join; +import com.yahoo.elide.modelconfig.model.Measure; +import com.yahoo.elide.modelconfig.model.Named; +import com.yahoo.elide.modelconfig.model.NamespaceConfig; +import com.yahoo.elide.modelconfig.model.Table; +import com.yahoo.elide.modelconfig.model.TableSource; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; +import org.antlr.v4.runtime.tree.ParseTree; +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.collections.CollectionUtils; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Slf4j +/** + * Util class to validate and parse the config files. Optionally compiles config files. + */ +public class DynamicConfigValidator implements DynamicConfiguration, Validator { + + private static final Set SQL_DISALLOWED_WORDS = new HashSet<>( + Arrays.asList("DROP", "TRUNCATE", "DELETE", "INSERT", "UPDATE", "ALTER", "COMMENT", "CREATE", "DESCRIBE", + "SHOW", "USE", "GRANT", "REVOKE", "CONNECT", "LOCK", "EXPLAIN", "CALL", "MERGE", "RENAME")); + private static final String SQL_SPLIT_REGEX = "\\s+"; + private static final String SEMI_COLON = ";"; + private static final Pattern HANDLEBAR_REGEX = Pattern.compile("<%(.*?)%>"); + private static final String RESOURCES = "resources"; + private static final int RESOURCES_LENGTH = 9; //"resources".length() + private static final String CLASSPATH_PATTERN = "classpath*:"; + private static final String FILEPATH_PATTERN = "file:"; + private static final String HJSON_EXTN = "**/*.hjson"; + + @Getter private final ElideTableConfig elideTableConfig = new ElideTableConfig(); + @Getter private ElideSecurityConfig elideSecurityConfig; + @Getter private Map modelVariables; + private Map dbVariables; + @Getter private final ElideDBConfig elideSQLDBConfig = new ElideSQLDBConfig(); + @Getter private final ElideNamespaceConfig elideNamespaceConfig = new ElideNamespaceConfig(); + private final DynamicConfigSchemaValidator schemaValidator = new DynamicConfigSchemaValidator(); + private final EntityDictionary dictionary; + private final FileLoader fileLoader; + + private static final Pattern FILTER_VARIABLE_PATTERN = Pattern.compile(".*?\\{\\{(\\w+)\\}\\}"); + + public DynamicConfigValidator(ClassScanner scanner, String configDir) { + dictionary = EntityDictionary.builder().scanner(scanner).build(); + fileLoader = new FileLoader(configDir); + + initialize(); + } + + private void initialize() { + Set> annotatedClasses = + dictionary.getScanner().getAnnotatedClasses(Arrays.asList(Include.class, SecurityCheck.class)); + + annotatedClasses.forEach(cls -> { + if (cls.getAnnotation(Include.class) != null) { + dictionary.bindEntity(cls); + } else { + dictionary.addSecurityCheck(cls); + } + }); + } + + public static void main(String[] args) { + Options options = prepareOptions(); + + try { + CommandLine cli = new DefaultParser().parse(options, args); + + if (cli.hasOption("help")) { + printHelp(options); + System.exit(0); + } + if (!cli.hasOption("configDir")) { + printHelp(options); + System.err.println("Missing required option"); + System.exit(1); + } + String configDir = cli.getOptionValue("configDir"); + + DynamicConfigValidator dynamicConfigValidator = + new DynamicConfigValidator(DefaultClassScanner.getInstance(), configDir); + dynamicConfigValidator.readAndValidateConfigs(); + System.out.println("Configs Validation Passed!"); + System.exit(0); + + } catch (Exception e) { + String msg = isBlank(e.getMessage()) ? "Process Failed!" : e.getMessage(); + System.err.println(msg); + System.exit(2); + } + } + + @Override + public void validate(Map resourceMap) { + + resourceMap.forEach((path, file) -> { + if (file.getContent() == null || file.getContent().isEmpty()) { + throw new BadRequestException(String.format("Null or empty file content for %s", file.getPath())); + } + + //Validate that all the files are ones we know about and are safe to manipulate... + if (file.getType().equals(ConfigFile.ConfigFileType.UNKNOWN)) { + throw new BadRequestException(String.format("Unrecognized File: %s", file.getPath())); + } + + if (path.contains("..")) { + throw new BadRequestException(String.format("Parent directory traversal not allowed: %s", + file.getPath())); + } + + //Validate that the file types and file paths match... + if (! file.getType().equals(FileLoader.toType(path))) { + throw new BadRequestException(String.format("File type %s does not match file path: %s", + file.getType(), file.getPath())); + } + }); + + readConfigs(resourceMap); + validateConfigs(); + } + + /** + * Read and validate config files under config directory. + * @throws IOException IOException + */ + public void readAndValidateConfigs() throws IOException { + Map loadedFiles = fileLoader.loadResources(); + + validate(loadedFiles); + } + + public void readConfigs() throws IOException { + readConfigs(fileLoader.loadResources()); + } + + public void readConfigs(Map resourceMap) { + this.modelVariables = readVariableConfig(Config.MODELVARIABLE, resourceMap); + this.elideSecurityConfig = readSecurityConfig(resourceMap); + this.dbVariables = readVariableConfig(Config.DBVARIABLE, resourceMap); + this.elideSQLDBConfig.setDbconfigs(readDbConfig(resourceMap)); + this.elideTableConfig.setTables(readTableConfig(resourceMap)); + this.elideNamespaceConfig.setNamespaceconfigs(readNamespaceConfig(resourceMap)); + populateInheritance(this.elideTableConfig); + } + + public void validateConfigs() { + validateSecurityConfig(); + boolean configurationExists = validateRequiredConfigsProvided(); + + if (configurationExists) { + validateNameUniqueness(this.elideSQLDBConfig.getDbconfigs(), + "Multiple DB configs found with the same name: "); + validateNameUniqueness(this.elideTableConfig.getTables(), + "Multiple Table configs found with the same name: "); + validateTableConfig(); + validateNameUniqueness(this.elideNamespaceConfig.getNamespaceconfigs(), + "Multiple Namespace configs found with the same name: "); + validateNamespaceConfig(); + validateJoinedTablesDBConnectionName(this.elideTableConfig); + } + } + + @Override + public Set
getTables() { + return elideTableConfig.getTables(); + } + + @Override + public Set getRoles() { + return elideSecurityConfig.getRoles(); + } + + @Override + public Set getDatabaseConfigurations() { + return elideSQLDBConfig.getDbconfigs(); + } + + @Override + public Set getNamespaceConfigurations() { + return elideNamespaceConfig.getNamespaceconfigs(); + } + + private static void validateInheritance(ElideTableConfig tables) { + tables.getTables().stream().forEach(table -> validateInheritance(tables, table, new HashSet<>())); + } + + private static void validateInheritance(ElideTableConfig tables, Table table, Set
visited) { + visited.add(table); + + if (!table.hasParent()) { + return; + } + Table parent = table.getParent(tables); + if (parent == null) { + throw new IllegalStateException( + "Undefined model: " + table.getExtend() + " is used as a Parent(extend) for another model."); + } + if (visited.contains(parent)) { + throw new IllegalStateException( + String.format("Inheriting from table '%s' creates an illegal cyclic dependency.", + parent.getName())); + } + validateInheritance(tables, parent, visited); + } + + private void populateInheritance(ElideTableConfig elideTableConfig) { + //ensures validation is run before populate always. + validateInheritance(this.elideTableConfig); + + Set
processed = new HashSet<>(); + elideTableConfig.getTables().stream().forEach(table -> populateInheritance(table, processed)); + } + + private void populateInheritance(Table table, Set
processed) { + if (processed.contains(table)) { + return; + } + + processed.add(table); + + if (!table.hasParent()) { + return; + } + + Table parent = table.getParent(this.elideTableConfig); + if (!processed.contains(parent)) { + populateInheritance(parent, processed); + } + + Map measures = getInheritedMeasures(parent, attributesListToMap(table.getMeasures())); + table.setMeasures(new ArrayList<>(measures.values())); + + Map dimensions = getInheritedDimensions(parent, attributesListToMap(table.getDimensions())); + table.setDimensions(new ArrayList<>(dimensions.values())); + + Map joins = getInheritedJoins(parent, attributesListToMap(table.getJoins())); + table.setJoins(new ArrayList<>(joins.values())); + + String schema = getInheritedSchema(parent, table.getSchema()); + table.setSchema(schema); + + String dbConnectionName = getInheritedConnection(parent, table.getDbConnectionName()); + table.setDbConnectionName(dbConnectionName); + + String sql = getInheritedSql(parent, table.getSql()); + table.setSql(sql); + + String tableName = getInheritedTable(parent, table.getTable()); + table.setTable(tableName); + + List arguments = getInheritedArguments(parent, table.getArguments()); + table.setArguments(arguments); + // isFact, isHidden, ReadAccess, namespace have default Values in schema, so can not be inherited. + // Other properties (tags, cardinality, etc.) have been categorized as non-inheritable too. + } + + private Map attributesListToMap(List attributes) { + return attributes.stream().collect(Collectors.toMap(T::getName, attribute -> attribute)); + } + + @FunctionalInterface + public interface Inheritance { + public T inherit(); + } + + private Map getInheritedMeasures(Table table, Map measures) { + Inheritance action = () -> { + table.getMeasures().forEach(measure -> { + if (!measures.containsKey(measure.getName())) { + measures.put(measure.getName(), measure); + } + }); + return measures; + }; + + action.inherit(); + return measures; + } + + private Map getInheritedDimensions(Table table, Map dimensions) { + Inheritance action = () -> { + table.getDimensions().forEach(dimension -> { + if (!dimensions.containsKey(dimension.getName())) { + dimensions.put(dimension.getName(), dimension); + } + }); + return dimensions; + }; + + action.inherit(); + return dimensions; + } + + private Map getInheritedJoins(Table table, Map joins) { + Inheritance action = () -> { + table.getJoins().forEach(join -> { + if (!joins.containsKey(join.getName())) { + joins.put(join.getName(), join); + } + }); + return joins; + }; + + action.inherit(); + return joins; + } + + private T getInheritedAttribute(Inheritance action, T property) { + return property == null ? (T) action.inherit() : property; + } + + private Collection getInheritedAttribute(Inheritance action, Collection property) { + return CollectionUtils.isEmpty(property) ? (Collection) action.inherit() : property; + } + + private String getInheritedSchema(Table table, String schema) { + Inheritance action = table::getSchema; + + return getInheritedAttribute(action, schema); + } + + private String getInheritedConnection(Table table, String connection) { + Inheritance action = table::getDbConnectionName; + + return getInheritedAttribute(action, connection); + } + + private String getInheritedSql(Table table, String sql) { + Inheritance action = table::getSql; + + return getInheritedAttribute(action, sql); + } + + private String getInheritedTable(Table table, String tableName) { + Inheritance action = table::getTable; + + return getInheritedAttribute(action, tableName); + } + + private List getInheritedArguments(Table table, List arguments) { + Inheritance action = table::getArguments; + + return (List) getInheritedAttribute(action, arguments); + } + + /** + * Read variable file config. + * @param config Config Enum + * @return Map A map containing all the variables if variable config exists else empty map + */ + private Map readVariableConfig(Config config, Map resourceMap) { + + return resourceMap + .entrySet() + .stream() + .filter(entry -> entry.getKey().startsWith(config.getConfigPath())) + .map(entry -> { + try { + return DynamicConfigHelpers.stringToVariablesPojo(entry.getKey(), + entry.getValue().getContent(), schemaValidator); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }) + .findFirst() + .orElse(new HashMap<>()); + } + + /** + * Read and validates security config file. + */ + private ElideSecurityConfig readSecurityConfig(Map resourceMap) { + + return resourceMap + .entrySet() + .stream() + .filter(entry -> entry.getKey().startsWith(Config.SECURITY.getConfigPath())) + .map(entry -> { + try { + String content = entry.getValue().getContent(); + validateConfigForMissingVariables(content, this.modelVariables); + return DynamicConfigHelpers.stringToElideSecurityPojo(entry.getKey(), + content, this.modelVariables, schemaValidator); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }) + .findAny() + .orElse(new ElideSecurityConfig()); + } + + /** + * Read and validates db config files. + * @return Set Set of SQL DB Configs + */ + private Set readDbConfig(Map resourceMap) { + + return resourceMap + .entrySet() + .stream() + .filter(entry -> entry.getKey().startsWith(Config.SQLDBConfig.getConfigPath())) + .map(entry -> { + try { + String content = entry.getValue().getContent(); + validateConfigForMissingVariables(content, this.dbVariables); + return DynamicConfigHelpers.stringToElideDBConfigPojo(entry.getKey(), + content, this.dbVariables, schemaValidator); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }) + .flatMap(dbconfig -> dbconfig.getDbconfigs().stream()) + .collect(Collectors.toSet()); + } + + /** + * Read and validates namespace config files. + * @return Set Set of Namespace Configs + */ + private Set readNamespaceConfig(Map resourceMap) { + + return resourceMap + .entrySet() + .stream() + .filter(entry -> entry.getKey().startsWith(Config.NAMESPACEConfig.getConfigPath())) + .map(entry -> { + try { + String content = entry.getValue().getContent(); + validateConfigForMissingVariables(content, this.modelVariables); + String fileName = entry.getKey(); + return DynamicConfigHelpers.stringToElideNamespaceConfigPojo(fileName, + content, this.modelVariables, schemaValidator); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }) + .flatMap(namespaceconfig -> namespaceconfig.getNamespaceconfigs().stream()) + .collect(Collectors.toSet()); + } + + /** + * Read and validates table config files. + */ + private Set
readTableConfig(Map resourceMap) { + + return resourceMap + .entrySet() + .stream() + .filter(entry -> entry.getKey().startsWith(Config.TABLE.getConfigPath())) + .map(entry -> { + try { + String content = entry.getValue().getContent(); + validateConfigForMissingVariables(content, this.modelVariables); + return DynamicConfigHelpers.stringToElideTablePojo(entry.getKey(), + content, this.modelVariables, schemaValidator); + } catch (IOException e) { + throw new IllegalStateException(e); + } + }) + .flatMap(table -> table.getTables().stream()) + .collect(Collectors.toSet()); + } + + /** + * Checks if neither Table nor DB config files provided. + */ + private boolean validateRequiredConfigsProvided() { + return !(this.elideTableConfig.getTables().isEmpty() && this.elideSQLDBConfig.getDbconfigs().isEmpty()); + } + + /** + * Extracts any handlebar variables in config file and checks if they are + * defined in variable config. Throw exception for undefined variables. + * @param config config file + * @param variables A map of defined variables + */ + private static void validateConfigForMissingVariables(String config, Map variables) { + Matcher regexMatcher = HANDLEBAR_REGEX.matcher(config); + while (regexMatcher.find()) { + String str = regexMatcher.group(1).trim(); + if (!variables.containsKey(str)) { + throw new IllegalStateException(str + " is used as a variable in either table or security config files " + + "but is not defined in variables config file."); + } + } + } + + /** + * Validate table configs. + * @return boolean true if all provided table properties passes validation + */ + private boolean validateTableConfig() { + Set extractedFieldChecks = new HashSet<>(); + Set extractedTableChecks = new HashSet<>(); + PermissionExpressionVisitor visitor = new PermissionExpressionVisitor(); + + for (Table table : elideTableConfig.getTables()) { + + validateSql(table.getSql()); + validateArguments(table, table.getArguments(), table.getFilterTemplate()); + //TODO - once tables support versions - replace NO_VERSION with apiVersion + validateNamespaceExists(table.getNamespace(), NO_VERSION); + Set tableFields = new HashSet<>(); + + table.getDimensions().forEach(dim -> { + validateFieldNameUniqueness(tableFields, dim.getName(), table.getName()); + validateSql(dim.getDefinition()); + validateTableSource(dim.getTableSource()); + validateArguments(table, dim.getArguments(), dim.getFilterTemplate()); + extractChecksFromExpr(dim.getReadAccess(), extractedFieldChecks, visitor); + }); + + table.getMeasures().forEach(measure -> { + validateFieldNameUniqueness(tableFields, measure.getName(), table.getName()); + validateSql(measure.getDefinition()); + validateArguments(table, measure.getArguments(), measure.getFilterTemplate()); + extractChecksFromExpr(measure.getReadAccess(), extractedFieldChecks, visitor); + }); + + table.getJoins().forEach(join -> { + validateFieldNameUniqueness(tableFields, join.getName(), table.getName()); + validateSql(join.getDefinition()); + validateModelExists(join.getTo()); + //TODO - once tables support versions - replace NO_VERSION with apiVersion + validateNamespaceExists(join.getNamespace(), NO_VERSION); + }); + + extractChecksFromExpr(table.getReadAccess(), extractedTableChecks, visitor); + } + + validateChecks(extractedTableChecks, extractedFieldChecks); + + return true; + } + + /** + * Validate namespace configs. + * @return boolean true if all provided namespace properties passes validation + */ + private boolean validateNamespaceConfig() { + Set extractedChecks = new HashSet<>(); + PermissionExpressionVisitor visitor = new PermissionExpressionVisitor(); + + for (NamespaceConfig namespace : elideNamespaceConfig.getNamespaceconfigs()) { + extractChecksFromExpr(namespace.getReadAccess(), extractedChecks, visitor); + } + + validateChecks(extractedChecks, Collections.EMPTY_SET); + + return true; + } + + private void validateArguments(Table table, List arguments, String requiredFilter) { + List allArguments = new ArrayList<>(arguments); + + /* Check for table arguments added in the required filter template */ + if (requiredFilter != null) { + Matcher matcher = FILTER_VARIABLE_PATTERN.matcher(requiredFilter); + while (matcher.find()) { + allArguments.add(Argument.builder() + .name(matcher.group(1)) + .build()); + } + } + + validateNameUniqueness(allArguments, "Multiple Arguments found with the same name: "); + arguments.forEach(arg -> validateTableSource(arg.getTableSource())); + } + + private void validateChecks(Set tableChecks, Set fieldChecks) { + + if (tableChecks.isEmpty() && fieldChecks.isEmpty()) { + return; // Nothing to validate + } + + Set staticChecks = dictionary.getCheckIdentifiers(); + + List undefinedChecks = Stream.concat(tableChecks.stream(), fieldChecks.stream()) + .filter(check -> !(elideSecurityConfig.hasCheckDefined(check) || staticChecks.contains(check))) + .sorted() + .collect(Collectors.toList()); + + if (!undefinedChecks.isEmpty()) { + throw new IllegalStateException("Found undefined security checks: " + undefinedChecks); + } + + tableChecks.stream() + .filter(check -> dictionary.getCheckMappings().containsKey(check)) + .forEach(check -> { + Class checkClass = dictionary.getCheck(check); + //Validates if the permission check either user Check or FilterExpressionCheck Check + if (!(UserCheck.class.isAssignableFrom(checkClass) + || FilterExpressionCheck.class.isAssignableFrom(checkClass))) { + throw new IllegalStateException("Table or Namespace cannot have Operation Checks. Given: " + + checkClass); + } + }); + fieldChecks.stream() + .filter(check -> dictionary.getCheckMappings().containsKey(check)) + .forEach(check -> { + Class checkClass = dictionary.getCheck(check); + //Validates if the permission check is User check + if (!UserCheck.class.isAssignableFrom(checkClass)) { + throw new IllegalStateException("Field can only have User checks or Roles. Given: " + + checkClass); + } + }); + } + + private static void extractChecksFromExpr(String readAccess, Set extractedChecks, + PermissionExpressionVisitor visitor) { + if (isNotBlank(readAccess)) { + ParseTree root = EntityPermissions.parseExpression(readAccess); + extractedChecks.addAll(visitor.visit(root)); + } + } + + private static void validateFieldNameUniqueness(Set alreadyFoundFields, String fieldName, + String tableName) { + if (!alreadyFoundFields.add(fieldName.toLowerCase(Locale.ENGLISH))) { + throw new IllegalStateException(String.format("Duplicate!! Field name: %s is not unique for table: %s", + fieldName, tableName)); + } + } + + /** + * Validates tableSource is in format: modelName.logicalColumnName and refers to a defined model and a defined + * column with in that model. + */ + private void validateTableSource(TableSource tableSource) { + if (tableSource == null) { + return; // Nothing to validate + } + + String modelName = Table.getModelName(tableSource.getTable(), tableSource.getNamespace()); + + if (elideTableConfig.hasTable(modelName)) { + Table lookupTable = elideTableConfig.getTable(modelName); + if (!lookupTable.hasField(tableSource.getColumn())) { + throw new IllegalStateException("Invalid tableSource : " + + tableSource + + " . Field : " + + tableSource.getColumn() + + " is undefined for hjson model: " + + tableSource.getTable()); + } + return; + } + + //TODO - once tables support versions - replace NO_VERSION with apiVersion + if (hasStaticModel(modelName, NO_VERSION)) { + if (!hasStaticField(modelName, NO_VERSION, tableSource.getColumn())) { + throw new IllegalStateException("Invalid tableSource : " + tableSource + + " . Field : " + tableSource.getColumn() + + " is undefined for non-hjson model: " + tableSource.getTable()); + } + return; + } + + throw new IllegalStateException("Invalid tableSource : " + tableSource + + " . Undefined model: " + tableSource.getTable()); + } + + /** + * Validates join clause does not refer to a Table which is not in the same DBConnection. If joined table is not + * part of dynamic configuration, then ignore + */ + private static void validateJoinedTablesDBConnectionName(ElideTableConfig elideTableConfig) { + + for (Table table : elideTableConfig.getTables()) { + if (!table.getJoins().isEmpty()) { + + Set joinedTables = table.getJoins() + .stream() + //TODO - NOT SURE + .map(Join::getTo) + .collect(Collectors.toSet()); + + Set connections = elideTableConfig.getTables() + .stream() + .filter(t -> joinedTables.contains(t.getGlobalName())) + .map(Table::getDbConnectionName) + .collect(Collectors.toSet()); + + if (connections.size() > 1 || (connections.size() == 1 + && !Objects.equals(table.getDbConnectionName(), connections.iterator().next()))) { + throw new IllegalStateException("DBConnection name mismatch between table: " + table.getName() + + " and tables in its Join Clause."); + } + } + } + } + + /** + * Validates table (or db connection) name is unique across all the dynamic table (or db connection) configs. + */ + public static void validateNameUniqueness(Collection configs, String errorMsg) { + + Set names = new HashSet<>(); + configs.forEach(obj -> { + if (!names.add(obj.getGlobalName().toLowerCase(Locale.ENGLISH))) { + throw new IllegalStateException(errorMsg + obj.getGlobalName()); + } + }); + } + + /** + * Check if input sql definition contains either semicolon or any of disallowed + * keywords. Throw exception if check fails. + */ + private static void validateSql(String sqlDefinition) { + if (isNotBlank(sqlDefinition) && (sqlDefinition.contains(SEMI_COLON) + || containsDisallowedWords(sqlDefinition, SQL_SPLIT_REGEX, SQL_DISALLOWED_WORDS))) { + throw new IllegalStateException("sql/definition provided in table config contain either '" + SEMI_COLON + + "' or one of these words: " + Arrays.toString(SQL_DISALLOWED_WORDS.toArray())); + } + } + + /** + * Validate role name provided in security config. + * @return boolean true if all role name passes validation else throw exception + */ + private boolean validateSecurityConfig() { + Set alreadyDefinedRoles = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + alreadyDefinedRoles.addAll(dictionary.getCheckIdentifiers()); + + elideSecurityConfig.getRoles().forEach(role -> { + if (alreadyDefinedRoles.contains(role)) { + throw new IllegalStateException(String.format( + "Duplicate!! Role name: '%s' is already defined. Please use different role.", role)); + } + alreadyDefinedRoles.add(role); + }); + + return true; + } + + private void validateModelExists(String name) { + if (!(elideTableConfig.hasTable(name) || hasStaticModel(name, NO_VERSION))) { + throw new IllegalStateException( + "Model: " + name + " is neither included in dynamic models nor in static models"); + } + } + + private void validateNamespaceExists(String name, String version) { + if (!elideNamespaceConfig.hasNamespace(name, version)) { + throw new IllegalStateException( + "Namespace: " + name + " is not included in dynamic configs"); + } + } + + /** + * Checks if any word in the input string matches any of the disallowed words. + * @param str input string to validate + * @param splitter regex for splitting input string + * @param keywords Set of disallowed words + * @return boolean true if any word in the input string matches any of the + * disallowed words else false + */ + private static boolean containsDisallowedWords(String str, String splitter, Set keywords) { + return isNotBlank(str) + && Arrays.stream(str.trim().toUpperCase(Locale.ENGLISH).split(splitter)).anyMatch(keywords::contains); + } + + /** + * Define Arguments. + */ + private static final Options prepareOptions() { + Options options = new Options(); + options.addOption(new Option("h", "help", false, "Print a help message and exit.")); + options.addOption(new Option("c", "configDir", true, + "Path for Configs Directory.\n" + + "Expected Directory Structure under Configs Directory:\n" + + "./models/security.hjson(optional)\n" + + "./models/variables.hjson(optional)\n" + + "./models/tables/(optional)\n" + + "./models/tables/table1.hjson\n" + + "./models/tables/table2.hjson\n" + + "./models/tables/tableN.hjson\n" + + "./db/variables.hjson(optional)\n" + + "./db/sql/(optional)\n" + + "./db/sql/db1.hjson\n" + + "./db/sql/db2.hjson\n" + + "./db/sql/dbN.hjson\n")); + + return options; + } + + /** + * Print Help. + */ + private static void printHelp(Options options) { + HelpFormatter formatter = new HelpFormatter(); + formatter.printHelp( + "java -cp com.yahoo.elide.modelconfig.validator.DynamicConfigValidator", + options); + } + + private boolean hasStaticField(String modelName, String version, String fieldName) { + Type modelType = dictionary.getEntityClass(modelName, version); + if (modelType == null) { + return false; + } + + try { + return (modelType.getDeclaredField(fieldName) != null); + } catch (NoSuchFieldException e) { + return false; + } + } + + private boolean hasStaticModel(String modelName, String version) { + Type modelType = dictionary.getEntityClass(modelName, version); + return modelType != null; + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/PermissionExpressionVisitor.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/PermissionExpressionVisitor.java new file mode 100644 index 0000000000..22e451e7b9 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/PermissionExpressionVisitor.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.modelconfig.validator; + +import com.yahoo.elide.generated.parsers.ExpressionBaseVisitor; +import com.yahoo.elide.generated.parsers.ExpressionParser; +import lombok.AllArgsConstructor; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Expression Visitor. + */ +@AllArgsConstructor +public class PermissionExpressionVisitor extends ExpressionBaseVisitor> { + + @Override + public Set visitNOT(ExpressionParser.NOTContext ctx) { + return visit(ctx.expression()); + } + + @Override + public Set visitOR(ExpressionParser.ORContext ctx) { + Set visit = visit(ctx.left); + visit.addAll(visit(ctx.right)); + return visit; + } + + @Override + public Set visitAND(ExpressionParser.ANDContext ctx) { + Set visit = visit(ctx.left); + visit.addAll(visit(ctx.right)); + return visit; + } + + @Override + public Set visitPAREN(ExpressionParser.PARENContext ctx) { + return visit(ctx.expression()); + } + + @Override + public Set visitPermissionClass(ExpressionParser.PermissionClassContext ctx) { + return new HashSet<>(Arrays.asList(ctx.getText())); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/Validator.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/Validator.java new file mode 100644 index 0000000000..098b4e5244 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/Validator.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.validator; + +import com.yahoo.elide.modelconfig.store.models.ConfigFile; + +import java.util.Map; + +/** + * Used to validate configuration. + */ +@FunctionalInterface +public interface Validator { + + /** + * Validate a full set of configurations. Throws an exception if there is an error. + * @param resourceMap Maps the path to the resource content. + */ + void validate(Map resourceMap); +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/verify/DynamicConfigVerifier.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/verify/DynamicConfigVerifier.java new file mode 100644 index 0000000000..66b4b25f7f --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/verify/DynamicConfigVerifier.java @@ -0,0 +1,166 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.verify; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; +import lombok.extern.slf4j.Slf4j; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.Certificate; +import java.util.Base64; + +/** + * Util class to Verify model tar.gz file's RSA signature with available public key in key store. + */ +@Slf4j +public class DynamicConfigVerifier { + + /** + * Main Method to Verify Signature of Model Tar file. + * @param args : expects 3 arguments. + */ + public static void main(String[] args) { + + Options options = prepareOptions(); + + try { + CommandLine cli = new DefaultParser().parse(options, args); + + if (cli.hasOption("help")) { + printHelp(options); + return; + } + if (!cli.hasOption("tarFile") || !cli.hasOption("signatureFile") || !cli.hasOption("publicKeyName")) { + printHelp(options); + System.err.println("Missing required option"); + System.exit(1); + } + + String modelTarFile = cli.getOptionValue("tarFile"); + String signatureFile = cli.getOptionValue("signatureFile"); + String publicKeyName = cli.getOptionValue("publicKeyName"); + + if (verify(readTarContents(modelTarFile), signatureFile, getPublicKey(publicKeyName))) { + System.out.println("Successfully Validated " + modelTarFile); + } else { + System.err.println("Could not verify " + modelTarFile + " with details provided"); + System.exit(2); + } + } catch (Exception e) { + System.err.println(e.getMessage()); + System.exit(3); + } + } + + /** + * Verify signature of tar.gz. + * @param fileContent : content Of all config files + * @param signature : file containing signature + * @param publicKey : public key name + * @return whether the file can be verified by given key and signature + * @throws NoSuchAlgorithmException If no Provider supports a Signature implementation for the SHA256withRSA + * algorithm. + * @throws InvalidKeyException If the {@code publicKey} is invalid. + * @throws SignatureException If Signature object is not initialized properly. + */ + public static boolean verify(String fileContent, String signature, PublicKey publicKey) + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { + + Signature publicSignature; + + publicSignature = Signature.getInstance("SHA256withRSA"); + publicSignature.initVerify(publicKey); + publicSignature.update(fileContent.getBytes(StandardCharsets.UTF_8)); + byte[] signatureBytes = Base64.getDecoder().decode(signature); + return publicSignature.verify(signatureBytes); + } + + /** + * Read Content of all files. + * @param archiveFile : tar.gz file path + * @return appended content of all files in tar + * @throws FileNotFoundException If {@code archiveFile} does not exist. + * @throws IOException If an I/O error occurs. + */ + public static String readTarContents(String archiveFile) throws FileNotFoundException, IOException { + StringBuffer sb = new StringBuffer(); + BufferedReader br = null; + + try (TarArchiveInputStream archiveInputStream = new TarArchiveInputStream( + new GzipCompressorInputStream(new BufferedInputStream(new FileInputStream(archiveFile))))) { + TarArchiveEntry entry = archiveInputStream.getNextTarEntry(); + while (entry != null) { + br = new BufferedReader(new InputStreamReader(archiveInputStream)); + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + entry = archiveInputStream.getNextTarEntry(); + } + } finally { + if (br != null) { + br.close(); + } + } + + return sb.toString(); + } + + /** + * Retrieve public key from Key Store. + * @param keyName : name of the public key + * @return publickey + */ + private static PublicKey getPublicKey(String keyName) throws KeyStoreException { + PublicKey publicKey = null; + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + Certificate cert = keyStore.getCertificate(keyName); + publicKey = cert.getPublicKey(); + return publicKey; + } + + /** + * Define Arguments. + */ + private static final Options prepareOptions() { + Options options = new Options(); + options.addOption(new Option("h", "help", false, "Print a help message and exit.")); + options.addOption(new Option("t", "tarFile", true, "Path of the tar.gz file")); + options.addOption(new Option("s", "signatureFile", true, "Path of the file containing the signature")); + options.addOption(new Option("p", "publicKeyName", true, "Name of public key in keystore")); + return options; + } + + /** + * Print Help. + */ + private static void printHelp(Options options) { + HelpFormatter formatter = new HelpFormatter(); + formatter.printHelp( + "java -cp com.yahoo.elide.contrib.dynamicconfighelpers.verify.DynamicConfigVerifier", + options); + } +} diff --git a/elide-model-config/src/main/resources/elideDBConfigSchema.json b/elide-model-config/src/main/resources/elideDBConfigSchema.json new file mode 100644 index 0000000000..371cbc2627 --- /dev/null +++ b/elide-model-config/src/main/resources/elideDBConfigSchema.json @@ -0,0 +1,128 @@ +{ + "$schema": "https://json-schema.org/draft-04/schema#", + "type": "object", + "title": "Elide DB Config Root Schema", + "description": "Elide database connection config json/hjson schema", + "required": [ + "dbconfigs" + ], + "additionalProperties": false, + "properties": { + "dbconfigs": { + "type": "array", + "title": "Elide DB Config Collection", + "description": "An array of Elide database connection configs.", + "uniqueItems": true, + "minItems": 1, + "items": { + "type": "object", + "title": "Elide DB Config", + "description": "Elide database connection config", + "required": [ + "name", + "url", + "driver", + "user", + "dialect" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "DB Connection Name", + "description": "Name of the database connection. This will be used for the persistent unit name.", + "format": "elideName", + "examples": [ + "MySQLConnection" + ] + }, + "url": { + "type": "string", + "title": "JDBC URL", + "description": "JDBC URL for the database connection i.e. javax.persistence.jdbc.URL", + "format": "elideJdbcUrl", + "examples": [ + "jdbc:mysql://localhost/elide?serverTimezone=UTC" + ] + }, + "driver": { + "type": "string", + "title": "JDBC Driver Name", + "description": "JDBC Driver for the database connection i.e. javax.persistence.jdbc.driver", + "format": "javaClassName", + "examples": [ + "com.mysql.jdbc.Driver" + ] + }, + "user": { + "type": "string", + "title": "DB Username", + "description": "Username for the database connection i.e. javax.persistence.jdbc.user", + "examples": [ + "guest1" + ] + }, + "dialect": { + "type": "string", + "title": "Elide Dialect", + "description": "The Elide Dialect to use for query generation.", + "format": "javaClassName", + "examples": [ + "com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.H2Dialect" + ] + }, + "propertyMap": { + "type": "object", + "title": "Additional Properties Map", + "description": "A map of additional Hibernate properties and persistence properties", + "default": {}, + "patternProperties": { + "^([A-Za-z0-9_]+[.]?)+$": { + "type": [ + "string", + "number", + "boolean", + "array", + "object" + ] + } + }, + "additionalProperties": false, + "examples": [ + { + "hibernate.show_sql": true, + "hibernate.default_batch_fetch_size": 100, + "hibernate.hbm2ddl.auto": "create" + } + ] + } + } + } + } + }, + "examples": [ + { + "dbconfigs": [ + { + "name": "MyDB2Connection", + "url": "jdbc:db2://localhost/elide?serverTimezone=UTC&", + "driver": "com.mysql.jdbc.Driver", + "user": "elide", + "dialect": "PrestoDB", + "propertyMap": { + "hibernate.show_sql": true, + "hibernate.default_batch_fetch_size": 100.1, + "hibernate.hbm2ddl.auto": "create" + } + }, + { + "name": "MySQLConnection", + "url": "jdbc:mysql://localhost/elide?serverTimezone=UTC", + "driver": "com.mysql.jdbc.Driver", + "user": "guest1", + "dialect": "com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.HiveDialect" + } + ] + } + ] +} diff --git a/elide-model-config/src/main/resources/elideNamespaceConfigSchema.json b/elide-model-config/src/main/resources/elideNamespaceConfigSchema.json new file mode 100644 index 0000000000..98ff167149 --- /dev/null +++ b/elide-model-config/src/main/resources/elideNamespaceConfigSchema.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://json-schema.org/draft-04/schema#", + "type": "object", + "title": "Elide Namespace Config Root Schema", + "description": "Elide Namespace config json/hjson schema", + "required": [ + "namespaces" + ], + "additionalProperties": false, + "properties": { + "namespaces": { + "type": "array", + "title": "Elide Namespace Config Collection", + "description": "An array of Elide Namespace configs.", + "uniqueItems": true, + "minItems": 1, + "items": { + "type": "object", + "title": "Elide Namespace Config", + "description": "Elide Namespace config", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "title": "Namespace Name", + "description": "Name of the Namespace. This can used for grouping the tables by subject area.", + "format": "elideNamespaceName", + "examples": [ + "MyNamespace" + ] + }, + "friendlyName": { + "title": "Namespace friendly name", + "description": "The friendly name of the Namespace. This will be displayed in the UI. If not provided, this defaults to the name", + "type": "string" + }, + "description": { + "title": "Namespace description", + "description": "A long description of the Namespace.", + "type": "string" + }, + "readAccess": { + "title": "Namespace read access", + "description": "Read permission for the Namespace.", + "type": "string", + "default": "Prefab.Role.All" + }, + "apiVersion": { + "title": "Namespace apiVersion", + "description": "Api Version for the Namespace.", + "type": "string", + "default": "" + } + } + } + } + }, + "examples": [ + { + "namespaces": [ + { + "name": "MyNamespace", + "friendlyName": "My Namespace", + "readAccess": "User is Admin", + "description": "A description of the MyNamespace namespace" + }, + { + "name": "AnotherNamespace", + "friendlyName": "Another Namespace", + "readAccess": "User is Editor", + "description": "A description of the AnotherNamespace namespace" + } + ] + } + ] +} diff --git a/elide-model-config/src/main/resources/elideSecuritySchema.json b/elide-model-config/src/main/resources/elideSecuritySchema.json new file mode 100644 index 0000000000..fa27e55313 --- /dev/null +++ b/elide-model-config/src/main/resources/elideSecuritySchema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft-04/schema#", + "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", + "format": "elideRole" + } + }, + "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-model-config/src/main/resources/elideTableSchema.json b/elide-model-config/src/main/resources/elideTableSchema.json new file mode 100644 index 0000000000..a868ba40cc --- /dev/null +++ b/elide-model-config/src/main/resources/elideTableSchema.json @@ -0,0 +1,600 @@ +{ + "$schema": "https://json-schema.org/draft-04/schema#", + "definitions": { + "argument" : { + "title" : "Arguments", + "description" : "Arguments are supported for table, measures and dimensions.", + "type" : "object", + "properties": { + "name": { + "title": "Argument name", + "description": "Name must start with an alphabetic character and can include alaphabets, numbers and '_' only.", + "type": "string", + "format": "elideArgumentName" + }, + "description": { + "title": "Argument description", + "description": "A long description for this Argument.", + "type": "string" + }, + "type": { + "title": "Argument type", + "description": "Must be one of Integer, Decimal, Money, Text, Coordinate, Boolean", + "type": "string", + "format": "elideFieldType" + }, + "values": { + "title": "Argument values", + "description": "An array of valid string values for this Argument", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "tableSource": { + "$ref": "#/definitions/tableSource" + }, + "default": { + "title": "Default argument value", + "description": "Default value for this argument.", + "type": [ + "string", + "number", + "boolean" + ] + } + }, + "required": [ + "name", + "type", + "default" + ], + "validateArgumentProperties": true, + "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", + "format": "elideFieldName" + }, + "namespace": { + "title": "Join Namespace", + "description": "Namespace for the Join.", + "type": "string", + "format": "elideNamespaceName", + "default": "default" + }, + "to": { + "title": "Join table name", + "description": "The name of the table that is being joined to", + "type": "string", + "format": "elideName" + }, + "type": { + "title": "Type of Join", + "description": "Type of the join - left, inner, full or cross", + "type": "string", + "format": "elideJoinType" + }, + "kind": { + "title": "Kind of Join", + "description": "Kind of the join - toOne or toMany", + "type": "string", + "format": "elideJoinKind" + }, + "definition": { + "title": "Join definition SQL", + "description": "Templated SQL expression that represents the ON clause of the join", + "type": "string" + } + }, + "required": [ + "name", + "to" + ], + "additionalProperties": false + }, + "tableSource": { + "title": "Table Source", + "description": "Provides information about where a column or argument values can be found.", + "type": "object", + "properties": { + "table": { + "title": "Source table", + "description": "The source table that contains the colum or argument values.", + "type": "string", + "format": "elideName" + }, + "namespace": { + "title": "Source table namespace", + "description": "Namespace for the source table.", + "type": "string", + "format": "elideNamespaceName", + "default": "default" + }, + "column": { + "title": "Primary source table column name", + "description": "The column that provides a unique list of values for the given column or argument", + "type": "string", + "format": "elideFieldName" + }, + "suggestionColumns": { + "title": "Secondary search columns", + "description": "Secondary columns that can be searched to locate the primary column(s)", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "format": "elideFieldName" + } + } + }, + "required": [ + "table", + "column" + ], + "additionalProperties": false + }, + "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", + "format": "elideFieldName" + }, + "friendlyName": { + "title": "Metric friendly name", + "description": "The friendly name of the metric. This will be displayed in the UI. If not provided, this defaults to the name", + "type": "string" + }, + "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" + }, + "maker": { + "title": "Metric Projection Maker", + "description": "Registers a custom function to create a projection for this metric", + "type": "string", + "format": "javaClassName" + }, + "type": { + "title": "Measure field type", + "description": "The data type of the measure field", + "type": "string", + "format": "elideFieldType" + }, + "tags": { + "title": "Measure tags", + "description": "An array of string based tags for measures", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "arguments": { + "title": "Measure arguments", + "description": "An array of supported arguments for measure", + "type": "array", + "items": { + "$ref": "#/definitions/argument" + } + }, + "filterTemplate": { + "title": "Required RSQL Filter Template", + "description": "Client queries must include a filter conforming to this RSQL template.", + "type": "string", + "format": "elideRSQLFilter" + } + }, + "oneOf": [ + { + "required": [ + "name", + "type", + "definition" + ] + }, + { + "required": [ + "name", + "type", + "maker" + ] + } + ], + "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", + "format": "elideFieldName" + }, + "friendlyName": { + "title": "Dimension friendly name", + "description": "The friendly name of the dimension. This will be displayed in the UI. If not provided, this defaults to the name", + "type": "string" + }, + "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" + }, + "cardinality": { + "title": "Dimension cardinality", + "description": "Dimension cardinality: (tiny, small, medium, large, huge). The relative sizes are decided by the table designer(s).", + "type": "string", + "format": "elideCardiality" + }, + "tags": { + "title": "Dimension tags", + "description": "An array of string based tags for dimensions", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "arguments": { + "title": "Dimension arguments", + "description": "An array of supported arguments for dimensions", + "type": "array", + "items": { + "$ref": "#/definitions/argument" + } + }, + "filterTemplate": { + "title": "Required RSQL Filter Template", + "description": "Client queries must include a filter conforming to this RSQL template.", + "type": "string", + "format": "elideRSQLFilter" + } + } + }, + "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": { + "title": "Dimension field type", + "description": "The data type of the dimension field", + "type": "string", + "format": "elideFieldType" + }, + "values": { + "title": "Dimension values", + "description": "An array of valid string values for this dimension", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "tableSource": { + "$ref": "#/definitions/tableSource" + } + } + } + ], + "validateDimensionProperties": true, + "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", + "format": "elideTimeFieldType" + }, + "grains" : { + "title" : "Time Dimension grains", + "description" : "Time Dimension granularity and Sqls", + "type" : "array", + "items" : { + "title": "Grains", + "description": "Grains can have SQL expressions that can substitute column with the dimension definition expression", + "type": "object", + "properties": { + "type": { + "title": "Time granularity", + "description": "Indicates grain time granularity", + "type": "string", + "format": "elideGrainType" + }, + "sql": { + "title": "Grain SQL", + "description": "Grain SQL query", + "type": "string" + } + }, + "additionalProperties": false + }, + "default" : [] + } + } + } + ], + "validateTimeDimensionProperties": true, + "required": [ + "name", + "type", + "definition" + ] + } + }, + "type": "object", + "title": "Elide Table Root Schema", + "description": "Elide Table config json/hjson schema", + "properties": { + "tables": { + "type": "array", + "title": "Elide Model Collection", + "description": "An array of Elide table configs.", + "uniqueItems": true, + "minItems": 1, + "items": { + "type": "object", + "title": "Elide Model", + "description": "Elide Table Config", + "properties": { + "name": { + "title": "Table Model Name", + "description": "The name of the model. This will be the same as the POJO class name.", + "type": "string", + "format": "elideName" + }, + "friendlyName": { + "title": "Table friendly name", + "description": "The friendly name of the table. This will be displayed in the UI. If not provided, this defaults to the name", + "type": "string" + }, + "filterTemplate": { + "title": "Required RSQL Filter Template", + "description": "Client queries must include a filter conforming to this RSQL template.", + "type": "string", + "format": "elideRSQLFilter" + }, + "isFact": { + "title": "Is Fact Table", + "description": "Whether this table is a fact", + "type": "boolean", + "default": true + }, + "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": "Table cardinality", + "description": "Table cardinality: (tiny, small, medium, large, huge). The relative sizes are decided by the table designer(s).", + "type": "string", + "format": "elideCardiality" + }, + "readAccess": { + "title": "Table read access", + "description": "Read permission for the table.", + "type": "string", + "default": "Prefab.Role.All" + }, + "namespace": { + "title": "Table Namespace", + "description": "Namespace for the table.", + "type": "string", + "format": "elideNamespaceName", + "default": "default" + }, + "hints": { + "title": "Optimizer Hints", + "description": "An array of hint names to control the optimizer", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "joins": { + "title": "Table joins", + "description": "Describes SQL joins to other tables for column references.", + "type": "array", + "items": { + "$ref": "#/definitions/join" + } + }, + "measures": { + "title": "Table measures", + "description": "Zero or more metric definitions.", + "type": "array", + "items": { + "$ref": "#/definitions/measure" + } + }, + "dimensions": { + "title": "Table dimensions", + "description": "One or more dimension definitions.", + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/dimension" + }, + { + "$ref": "#/definitions/timeDimension" + } + ] + } + }, + "tags": { + "title": "Table tags", + "description": "An array of string based tags", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "arguments": { + "title": "Table arguments", + "description": "An array of supported arguments for tables.", + "type": "array", + "items": { + "$ref": "#/definitions/argument" + } + } + }, + "oneOf": [ + { + "properties": { + "sql": { + "title": "Table SQL", + "description": "SQL query which is used to populate the table.", + "type": "string" + }, + "dbConnectionName": { + "title": "DB Connection Name", + "description": "The database connection name for this model.", + "type": "string", + "format": "elideName" + } + }, + "required": [ + "name", + "sql", + "dimensions" + ] + }, + { + "properties": { + "schema": { + "title": "Table Schema", + "description": "The database or schema where the model lives.", + "type": "string" + }, + "table": { + "title": "Table name", + "description": "The physical table name where the model lives.", + "type": "string" + }, + "dbConnectionName": { + "title": "DB Connection Name", + "description": "The database connection name for this model.", + "type": "string", + "format": "elideName" + } + }, + "required": [ + "name", + "table", + "dimensions" + ] + }, + { + "properties": { + "extend": { + "title": "Table Extends", + "description": "Extends another logical table.", + "type": "string", + "format": "elideName" + } + }, + "required": [ + "name", + "extend" + ] + } + ] + } + } + }, + "required": [ + "tables" + ], + "additionalProperties": false +} diff --git a/elide-model-config/src/main/resources/elideVariableSchema.json b/elide-model-config/src/main/resources/elideVariableSchema.json new file mode 100644 index 0000000000..3b8d46d1a8 --- /dev/null +++ b/elide-model-config/src/main/resources/elideVariableSchema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft-04/schema#", + "description": "Elide Variable config json/hjson schema", + "type": "object", + "patternProperties": { + "^([A-Za-z0-9_]+[.]?)+$": { + "type": [ + "string", + "number", + "boolean", + "array", + "object" + ] + } + }, + "additionalProperties": false, + "minProperties": 1 +} diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidatorTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidatorTest.java new file mode 100644 index 0000000000..7ec2d9c74f --- /dev/null +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidatorTest.java @@ -0,0 +1,193 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.hjson.JsonValue; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.InputStreamReader; +import java.io.Reader; + +public class DynamicConfigSchemaValidatorTest { + + private DynamicConfigSchemaValidator testClass = new DynamicConfigSchemaValidator(); + + @Test + public void testValidSecuritySchemas() throws Exception { + String jsonConfig = loadHjsonFromClassPath("/validator/valid/models/security.hjson"); + assertTrue(testClass.verifySchema(Config.SECURITY, jsonConfig, "security.hjson")); + } + + @Test + public void testInvalidSecuritySchema() throws Exception { + String jsonConfig = loadHjsonFromClassPath("/validator/invalid_schema/security_invalid.hjson"); + Exception e = assertThrows(IllegalStateException.class, + () -> testClass.verifySchema(Config.SECURITY, jsonConfig, "security_invalid.hjson")); + String expectedMessage = "Schema validation failed for: security_invalid.hjson\n" + + "[ERROR]\n" + + "object instance has properties which are not allowed by the schema: [\"cardinality\",\"description\",\"name\",\"schema$\",\"table\"]"; + assertEquals(expectedMessage, e.getMessage()); + } + + @Test + public void testValidVariableSchema() throws Exception { + String jsonConfig = loadHjsonFromClassPath("/validator/valid/models/variables.hjson"); + assertTrue(testClass.verifySchema(Config.MODELVARIABLE, jsonConfig, "variables.hjson")); + } + + @Test + public void testInvalidVariableSchema() throws Exception { + String jsonConfig = loadHjsonFromClassPath("/validator/invalid_schema/variables_invalid.hjson"); + Exception e = assertThrows(IllegalStateException.class, + () -> testClass.verifySchema(Config.MODELVARIABLE, jsonConfig, "variables.hjson")); + String expectedMessage = "Schema validation failed for: variables.hjson\n" + + "[ERROR]\n" + + "object instance has properties which are not allowed by the schema: [\"schema$\"]\n" + + "[ERROR]\n" + + "Instance[/cardinality] failed to validate against schema[/patternProperties/^([A-Za-z0-9_]+[.]?)+$]. instance type (null) does not match any allowed primitive type (allowed: [\"array\",\"boolean\",\"integer\",\"number\",\"object\",\"string\"])"; + assertEquals(expectedMessage, e.getMessage()); + } + + // Table config test + @DisplayName("Valid Table config") + @ParameterizedTest + @ValueSource(strings = { + "/validator/valid/models/tables/player_stats.hjson", + "/validator/valid/models/tables/player_stats_extends.hjson"}) + public void testValidTableSchema(String resource) throws Exception { + String jsonConfig = loadHjsonFromClassPath(resource); + String fileName = getFileName(resource); + assertTrue(testClass.verifySchema(Config.TABLE, jsonConfig, fileName)); + } + + @DisplayName("Invalid Table config") + @ParameterizedTest + @ValueSource(strings = { + "/validator/invalid_schema/table_invalid.hjson", + "/validator/invalid_schema/invalid_dimension_data_source.hjson", + "/validator/invalid_schema/invalid_query_plan_classname.hjson", + "/validator/invalid_schema/invalid_table_filter.hjson"}) + public void testInvalidTableSchema(String resource) throws Exception { + String jsonConfig = loadHjsonFromClassPath(resource); + String fileName = getFileName(resource); + Exception e = assertThrows(IllegalStateException.class, + () -> testClass.verifySchema(Config.TABLE, jsonConfig, fileName)); + assertTrue(e.getMessage().startsWith("Schema validation failed for: " + fileName)); + } + + @DisplayName("Invalid Table config") + @ParameterizedTest + @ValueSource(strings = {"/validator/invalid_schema/table_schema_with_multiple_errors.hjson"}) + public void testInvalidTableSchemaMultipleErrors(String resource) throws Exception { + String jsonConfig = loadHjsonFromClassPath(resource); + String fileName = getFileName(resource); + Exception e = assertThrows(IllegalStateException.class, + () -> testClass.verifySchema(Config.TABLE, jsonConfig, fileName)); + String expectedMessage = "Schema validation failed for: table_schema_with_multiple_errors.hjson\n" + + "[ERROR]\n" + + "object instance has properties which are not allowed by the schema: [\"name\"]\n" + + "[ERROR]\n" + + "Instance[/tables/0/arguments/0] failed to validate against schema[/definitions/argument]. object has missing required properties ([\"default\"])\n" + + "[ERROR]\n" + + "Instance[/tables/0/arguments/0/type] failed to validate against schema[/definitions/argument/properties/type]. Field type [Number] is not allowed. Supported value is one of [Integer, Decimal, Money, Text, Coordinate, Boolean, Enum_Text, Enum_Ordinal].\n" + + "[ERROR]\n" + + "Instance[/tables/0/arguments/1] failed to validate against schema[/definitions/argument]. object has missing required properties ([\"default\"])\n" + + "[ERROR]\n" + + "Instance[/tables/0/arguments/1/name] failed to validate against schema[/definitions/argument/properties/name]. Argument name [Grain] is not allowed. Argument name cannot be 'grain'.\n" + + "[ERROR]\n" + + "Instance[/tables/0/arguments/2] failed to validate against schema[/definitions/argument]. tableSource and values cannot both be defined for an argument. Choose One or None.\n" + + "[ERROR]\n" + + "Instance[/tables/0/cardinality] failed to validate against schema[/properties/tables/items/properties/cardinality]. Cardinality type [Extra Large] is not allowed. Supported value is one of [Tiny, Small, Medium, Large, Huge].\n" + + "[ERROR]\n" + + "Instance[/tables/0/dimensions/0] failed to validate against schema[/properties/tables/items/properties/dimensions/items]. instance failed to match exactly one schema (matched 0 out of 2)\n" + + " Instance[/tables/0/dimensions/0] failed to validate against schema[/definitions/dimension]. instance failed to match all required schemas (matched only 0 out of 2)\n" + + " Instance[/tables/0/dimensions/0/cardinality] failed to validate against schema[/definitions/dimensionRef/properties/cardinality]. Cardinality type [Extra small] is not allowed. Supported value is one of [Tiny, Small, Medium, Large, Huge].\n" + + " Instance[/tables/0/dimensions/0/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [id] is not allowed. Field name cannot be one of [id, sql]\n" + + " Instance[/tables/0/dimensions/0/type] failed to validate against schema[/definitions/dimension/allOf/1/properties/type]. Field type [Float] is not allowed. Supported value is one of [Integer, Decimal, Money, Text, Coordinate, Boolean, Enum_Text, Enum_Ordinal].\n" + + " Instance[/tables/0/dimensions/0] failed to validate against schema[/definitions/timeDimension]. instance failed to match all required schemas (matched only 0 out of 2)\n" + + " Instance[/tables/0/dimensions/0/cardinality] failed to validate against schema[/definitions/dimensionRef/properties/cardinality]. Cardinality type [Extra small] is not allowed. Supported value is one of [Tiny, Small, Medium, Large, Huge].\n" + + " Instance[/tables/0/dimensions/0/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [id] is not allowed. Field name cannot be one of [id, sql]\n" + + " Instance[/tables/0/dimensions/0/type] failed to validate against schema[/definitions/timeDimension/allOf/1/properties/type]. Field type [Float] is not allowed. Field type must be [Time] for any time dimension.\n" + + " Instance[/tables/0/dimensions/0] failed to validate against schema[/definitions/timeDimension]. Properties: [tableSource] are not allowed for time dimensions.\n" + + "[ERROR]\n" + + "Instance[/tables/0/dimensions/1] failed to validate against schema[/properties/tables/items/properties/dimensions/items]. instance failed to match exactly one schema (matched 0 out of 2)\n" + + " Instance[/tables/0/dimensions/1] failed to validate against schema[/definitions/dimension]. instance failed to match all required schemas (matched only 1 out of 2)\n" + + " Instance[/tables/0/dimensions/1/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [_region] is not allowed. Field name must start with lower case alphabet and can include alaphabets, numbers and '_' only.\n" + + " Instance[/tables/0/dimensions/1/tags] failed to validate against schema[/definitions/dimensionRef/properties/tags]. instance type (string) does not match any allowed primitive type (allowed: [\"array\"])\n" + + " Instance[/tables/0/dimensions/1] failed to validate against schema[/definitions/dimension]. tableSource and values cannot both be defined for a dimension. Choose One or None.\n" + + " Instance[/tables/0/dimensions/1] failed to validate against schema[/definitions/timeDimension]. instance failed to match all required schemas (matched only 0 out of 2)\n" + + " Instance[/tables/0/dimensions/1/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [_region] is not allowed. Field name must start with lower case alphabet and can include alaphabets, numbers and '_' only.\n" + + " Instance[/tables/0/dimensions/1/tags] failed to validate against schema[/definitions/dimensionRef/properties/tags]. instance type (string) does not match any allowed primitive type (allowed: [\"array\"])\n" + + " Instance[/tables/0/dimensions/1/type] failed to validate against schema[/definitions/timeDimension/allOf/1/properties/type]. Field type [Text] is not allowed. Field type must be [Time] for any time dimension.\n" + + " Instance[/tables/0/dimensions/1] failed to validate against schema[/definitions/timeDimension]. Properties: [values, tableSource] are not allowed for time dimensions.\n" + + "[ERROR]\n" + + "Instance[/tables/0/dimensions/2] failed to validate against schema[/properties/tables/items/properties/dimensions/items]. instance failed to match exactly one schema (matched 0 out of 2)\n" + + " Instance[/tables/0/dimensions/2] failed to validate against schema[/definitions/dimension]. instance failed to match all required schemas (matched only 1 out of 2)\n" + + " Instance[/tables/0/dimensions/2/type] failed to validate against schema[/definitions/dimension/allOf/1/properties/type]. Field type [TIMEX] is not allowed. Supported value is one of [Integer, Decimal, Money, Text, Coordinate, Boolean, Enum_Text, Enum_Ordinal].\n" + + " Instance[/tables/0/dimensions/2] failed to validate against schema[/definitions/dimension]. Properties: [grains] are not allowed for dimensions.\n" + + " Instance[/tables/0/dimensions/2] failed to validate against schema[/definitions/timeDimension]. instance failed to match all required schemas (matched only 1 out of 2)\n" + + " Instance[/tables/0/dimensions/2/grains/0/type] failed to validate against schema[/definitions/timeDimension/allOf/1/properties/grains/items/properties/type]. Grain type [Days] is not allowed. Supported value is one of [Second, Minute, Hour, Day, IsoWeek, Week, Month, Quarter, Year].\n" + + " Instance[/tables/0/dimensions/2/type] failed to validate against schema[/definitions/timeDimension/allOf/1/properties/type]. Field type [TIMEX] is not allowed. Field type must be [Time] for any time dimension.\n" + + "[ERROR]\n" + + "Instance[/tables/0/filterTemplate] failed to validate against schema[/properties/tables/items/properties/filterTemplate]. Input value[countryIsoCode={{code}};startTime=={{start}}] is not a valid RSQL filter expression. Please visit page https://elide.io/pages/guide/v5/11-graphql.html#operators for samples.\n" + + "[ERROR]\n" + + "Instance[/tables/0/measures/0] failed to validate against schema[/definitions/measure]. instance failed to match exactly one schema (matched 2 out of 2)\n" + + "[ERROR]\n" + + "Instance[/tables/0/measures/0/maker] failed to validate against schema[/definitions/measure/properties/maker]. Input value[com.yahoo.elide.datastores.aggregation.query@DefaultMetricProjectionMaker.class] is not a valid Java class name.\n" + + "[ERROR]\n" + + "Instance[/tables/0/name] failed to validate against schema[/properties/tables/items/properties/name]. Name [Country@10] is not allowed. Name must start with an alphabetic character and can include alaphabets, numbers and '_' only."; + + assertEquals(expectedMessage, e.getMessage()); + } + + // DB config test + @DisplayName("Valid DB config") + @ParameterizedTest + @ValueSource(strings = { + "/validator/valid/db/sql/multiple_db_no_variables.hjson", + "/validator/valid/db/sql/single_db.hjson"}) + public void testValidDbSchema(String resource) throws Exception { + String jsonConfig = loadHjsonFromClassPath(resource); + String fileName = getFileName(resource); + assertTrue(testClass.verifySchema(Config.SQLDBConfig, jsonConfig, fileName)); + } + + @Test + public void testInvalidDbSchema() throws Exception { + String jsonConfig = loadHjsonFromClassPath("/validator/invalid_schema/db_invalid.hjson"); + Exception e = assertThrows(IllegalStateException.class, + () -> testClass.verifySchema(Config.SQLDBConfig, jsonConfig, "db_invalid.hjson")); + String expectedMessage = "Schema validation failed for: db_invalid.hjson\n" + + "[ERROR]\n" + + "Instance[/dbconfigs/0/driver] failed to validate against schema[/properties/dbconfigs/items/properties/driver]. Input value[11COM.ibm.db2.jdbc.net.DB2Driver] is not a valid Java class name.\n" + + "[ERROR]\n" + + "Instance[/dbconfigs/0/name] failed to validate against schema[/properties/dbconfigs/items/properties/name]. Name [11MyDB2Connection] is not allowed. Name must start with an alphabetic character and can include alaphabets, numbers and '_' only.\n" + + "[ERROR]\n" + + "Instance[/dbconfigs/0/propertyMap/hibernate.show_sql] failed to validate against schema[/properties/dbconfigs/items/properties/propertyMap/patternProperties/^([A-Za-z0-9_]+[.]?)+$]. instance type (null) does not match any allowed primitive type (allowed: [\"array\",\"boolean\",\"integer\",\"number\",\"object\",\"string\"])\n" + + "[ERROR]\n" + + "Instance[/dbconfigs/1/dialect] failed to validate against schema[/properties/dbconfigs/items/properties/dialect]. instance type (integer) does not match any allowed primitive type (allowed: [\"string\"])\n" + + "[ERROR]\n" + + "Instance[/dbconfigs/1/url] failed to validate against schema[/properties/dbconfigs/items/properties/url]. Input value [ojdbc:mysql://localhost/testdb?serverTimezone=UTC] is not a valid JDBC url, it must start with 'jdbc:'."; + assertEquals(expectedMessage, e.getMessage()); + } + + private String loadHjsonFromClassPath(String resource) throws Exception { + Reader reader = new InputStreamReader( + DynamicConfigSchemaValidatorTest.class.getResourceAsStream(resource)); + return JsonValue.readHjson(reader).toString(); + } + + private String getFileName(String resource) throws Exception { + String file = DynamicConfigSchemaValidatorTest.class.getResource(resource).getFile(); + return file.substring(file.lastIndexOf('/') + 1); + } +} diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/io/FileLoaderTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/io/FileLoaderTest.java new file mode 100644 index 0000000000..83777629a0 --- /dev/null +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/io/FileLoaderTest.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.io; + +import static com.yahoo.elide.modelconfig.io.FileLoader.formatClassPath; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; + +public class FileLoaderTest { + + @Test + public void testFormatClassPath() { + assertEquals("anydir", formatClassPath("src/test/resources/anydir")); + assertEquals("anydir/configs", formatClassPath("src/test/resources/anydir/configs")); + assertEquals("src/test/resourc", formatClassPath("src/test/resourc")); + assertEquals("", formatClassPath("src/test/resources/")); + assertEquals("", formatClassPath("src/test/resources")); + assertEquals("anydir/configs", formatClassPath("src/test/resourcesanydir/configs")); + } +} diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydratorTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydratorTest.java new file mode 100644 index 0000000000..127e558fd2 --- /dev/null +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydratorTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.parser.handlebars; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +@TestInstance(Lifecycle.PER_CLASS) +public class HandlebarsHydratorTest { + + private static final String CONFIG_PATH = "src/test/resources/validator/valid"; + + private static final String VALID_TABLE_WITH_VARIABLES = "{\n" + + " tables: [{\n" + + " name: <% name %>\n" + + " namespace: PlayerNamespace\n" + + " friendlyName: Player Statistics\n" + + " table: <% table %>\n" + + " schema: gamedb\n" + + " description:\n" + + " // newlines are replaced by single space in handlebar if no helper function is applied\n" + + " '''\n" + + " A long description\n" + + " with newline\n" + + " and additional space at start of this line.\n" + + " '''\n" + + " category: Table Category\n" + + " cardinality : lARge\n" + + " hidden : false\n" + + " readAccess : (user AND member) OR (admin AND NOT gu.est user)\n" + + " filterTemplate : countryIsoCode=={{code}}\n" + + " tags: ['GAME', 'PLAYER', '''\n" + + " A tag\n" + + " with newline\n" + + " ''']\n" + + " hints: ['NoAggregateBeforeJoin']\n" + + " arguments: [\n" + + " {\n" + + " name: scoreFormat\n" + + " type: TEXT\n" + + " default: 999999D00\n" + + " }\n" + + " {\n" + + " name: countryCode\n" + + " type: TEXT\n" + + " tableSource: {\n" + + " table: Country\n" + + " column: isoCode\n" + + " }\n" + + " default: US\n" + + " }\n" + + " ]\n" + + " joins: [\n" + + " {\n" + + " name: playerCountry\n" + + " to: Country\n" + + " type: Cross\n" + + " },\n" + + " {\n" + + " name: playerTeam\n" + + " to: Team\n" + + " kind: Tomany\n" + + " type: left\n" + + " definition: '{{playerTeam.id}} = {{ team_id}}'\n" + + " }\n" + + " ]\n" + + "\n" + + " measures : [\n" + + " {\n" + + " name : highScore\n" + + " friendlyName : High Score\n" + + " type : INteGER\n" + + " description : very awesome score\n" + + " definition: 'MAX({{score}})'\n" + + " tags: ['PUBLIC']\n" + + " },\n" + + " {\n" + + " name : newHighScore\n" + + " type : INteGER\n" + + " description : very awesome score\n" + + " definition: 'MAX({{score}})'\n" + + " tags: ['PUBLIC']\n" + + " }\n" + + " ]\n" + + " dimensions : [\n" + + " {\n" + + " name : countryIsoCode\n" + + " friendlyName : Country ISO Code\n" + + " type : TEXT\n" + + " category : country detail\n" + + " definition : '{{playerCountry.isoCode}}'\n" + + " values : ['US', 'HK']\n" + + " tags: ['PRIVATE']\n" + + " cardinality: Small\n" + + " },\n" + + " {\n" + + " name : teamRegion\n" + + " type : TEXT\n" + + " definition : '{{playerTeam.region}}'\n" + + " tableSource: {\n" + + " table: PlayerStatsChild\n" + + " namespace: PlayerNamespace\n" + + " column: teamRegion\n" + + " }\n" + + " },\n" + + " {\n" + + " name : createdOn\n" + + " friendlyName : Created On\n" + + " type : TIME\n" + + " definition : '{{create_on}}'\n" + + " filterTemplate : 'createdOn=={{createdOn}}'\n" + + " grains:\n" + + " [{\n" + + " type : DaY\n" + + " sql : '''\n" + + " PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd')\n" + + " '''\n" + + " }]\n" + + " },\n" + + " {\n" + + " name : updatedOn\n" + + " type : TIme\n" + + " definition : '{{updated_on}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}\n"; + + private DynamicConfigValidator testClass; + private HandlebarsHydrator hydrator; + + @BeforeAll + public void setup() throws IOException { + hydrator = new HandlebarsHydrator(); + testClass = new DynamicConfigValidator(DefaultClassScanner.getInstance(), CONFIG_PATH); + testClass.readConfigs(); + } + + @Test + public void testConfigHydration() throws IOException { + File file = new File(CONFIG_PATH); + String hjsonPath = file.getAbsolutePath() + "/models/tables/player_stats.hjson"; + String content = new String(Files.readAllBytes(Paths.get(hjsonPath))); + + assertEquals(content, hydrator.hydrateConfigTemplate( + VALID_TABLE_WITH_VARIABLES, testClass.getModelVariables())); + } +} diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTest.java new file mode 100644 index 0000000000..df8a2805a4 --- /dev/null +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTest.java @@ -0,0 +1,502 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static com.yahoo.elide.modelconfig.store.ConfigDataStore.VALIDATE_ONLY_HEADER; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.DATABASE; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.NAMESPACE; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.SECURITY; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.TABLE; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.VARIABLE; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.toId; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; +import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; +import com.yahoo.elide.modelconfig.validator.Validator; +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +@Slf4j +public class ConfigDataStoreTest { + + @Test + public void testLoadObjects() { + String configRoot = "src/test/resources/validator/valid"; + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigDataStoreTransaction tx = store.beginReadTransaction(); + RequestScope scope = mock(RequestScope.class); + + DataStoreIterable loaded = tx.loadObjects(EntityProjection.builder() + .type(ClassType.of(ConfigFile.class)).build(), scope); + + List configFiles = Lists.newArrayList(loaded.iterator()); + + Supplier contentProvider = () -> + "{\n" + + " dbconfigs:\n" + + " [\n" + + " {\n" + + " name: MyDB2Connection\n" + + " url: jdbc:db2:localhost:50000/testdb\n" + + " driver: COM.ibm.db2.jdbc.net.DB2Driver\n" + + " user: guestdb2\n" + + " dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.PrestoDBDialect\n" + + " propertyMap:\n" + + " {\n" + + " hibernate.show_sql: true\n" + + " hibernate.default_batch_fetch_size: 100.1\n" + + " hibernate.hbm2ddl.auto: create\n" + + " }\n" + + " }\n" + + " {\n" + + " name: MySQLConnection\n" + + " url: jdbc:mysql://localhost/testdb?serverTimezone=UTC\n" + + " driver: com.mysql.jdbc.Driver\n" + + " user: guestmysql\n" + + " dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.HiveDialect\n" + + " }\n" + + " ]\n" + + "}\n"; + + assertEquals(10, configFiles.size()); + assertTrue(compare(ConfigFile.builder() + .version("") + .type(ConfigFile.ConfigFileType.DATABASE) + .contentProvider(contentProvider) + .path("db/sql/multiple_db_no_variables.hjson") + .build(), configFiles.get(1))); + + assertEquals("db/sql/multiple_db.hjson", configFiles.get(0).getPath()); + assertEquals(DATABASE, configFiles.get(0).getType()); + + assertEquals("db/sql/single_db.hjson", configFiles.get(2).getPath()); + assertEquals(DATABASE, configFiles.get(2).getType()); + + assertEquals("db/variables.hjson", configFiles.get(3).getPath()); + assertEquals(VARIABLE, configFiles.get(3).getType()); + + assertEquals("models/namespaces/player.hjson", configFiles.get(4).getPath()); + assertEquals(NAMESPACE, configFiles.get(4).getType()); + + assertEquals("models/security.hjson", configFiles.get(5).getPath()); + assertEquals(SECURITY, configFiles.get(5).getType()); + + assertEquals("models/tables/player_stats.hjson", configFiles.get(6).getPath()); + assertEquals(TABLE, configFiles.get(6).getType()); + + assertEquals("models/tables/player_stats_extends.hjson", configFiles.get(7).getPath()); + assertEquals(TABLE, configFiles.get(7).getType()); + + assertEquals("models/tables/referred_model.hjson", configFiles.get(8).getPath()); + assertEquals(TABLE, configFiles.get(8).getType()); + + assertEquals("models/variables.hjson", configFiles.get(9).getPath()); + assertEquals(VARIABLE, configFiles.get(9).getType()); + } + + @Test + public void testCreate(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigFile newFile = createFile("test", store, false); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + RequestScope scope = mock(RequestScope.class); + + ConfigFile loaded = readTx.loadObject(EntityProjection.builder().type(ClassType.of(ConfigFile.class)).build(), + toId("models/tables/test.hjson", NO_VERSION), scope); + + assertTrue(compare(newFile, loaded)); + } + + @Test + public void testCreateReadOnly() { + + //This path is read only (Classpath)... + String configRoot = "src/test/resources/validator/valid"; + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + assertThrows(UnsupportedOperationException.class, + () -> createFile("test", store, false)); + } + + @Test + public void testCreateInvalid(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + assertThrows(BadRequestException.class, + () -> createInvalidFile(configRoot, store)); + } + + @Test + public void testCreateValidateOnly(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + RequestScope scope = mock(RequestScope.class); + + ConfigFile loaded = readTx.loadObject(EntityProjection.builder().type(ClassType.of(ConfigFile.class)).build(), + toId("models/tables/test.hjson", NO_VERSION), scope); + + assertNull(loaded); + } + + @Test + public void testUpdate(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + createFile("test", store, false); + ConfigFile updateFile = updateFile(configRoot, store); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + RequestScope scope = mock(RequestScope.class); + + ConfigFile loaded = readTx.loadObject(EntityProjection.builder().type(ClassType.of(ConfigFile.class)).build(), + toId("models/tables/test.hjson", NO_VERSION), scope); + + assertTrue(compare(updateFile, loaded)); + } + + @Test + public void testUpdateWithPermissionError(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigFile createdFile = createFile("test", store, false); + + String createdFilePath = Path.of(configPath.toFile().getPath(), createdFile.getPath()).toFile().getPath(); + + File file = new File(createdFilePath); + boolean blockFailed = blockWrites(file); + + if (blockFailed) { + //We can't actually test because setting permissions isn't working. + return; + } + + assertThrows(UnsupportedOperationException.class, () -> updateFile(configRoot, store)); + } + + @Test + public void testDeleteWithPermissionError(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigFile createdFile = createFile("test", store, false); + + String createdFilePath = Path.of(configPath.toFile().getPath(), createdFile.getPath()).toFile().getPath(); + + File file = new File(createdFilePath); + boolean blockFailed = blockWrites(file); + + if (blockFailed) { + //We can't actually test because setting permissions isn't working. + return; + } + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + assertThrows(UnsupportedOperationException.class, () -> tx.delete(createdFile, scope)); + } + + @Test + public void testCreateWithPermissionError(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + File file = configPath.toFile(); + boolean blockFailed = blockWrites(file); + + if (blockFailed) { + //We can't actually test because setting permissions isn't working. + return; + } + + assertThrows(UnsupportedOperationException.class, () -> createFile("test", store, false)); + } + + @Test + public void testDelete(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigFile newFile = createFile("test", store, false); + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + tx.delete(newFile, scope); + + tx.flush(scope); + tx.commit(scope); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + + ConfigFile loaded = readTx.loadObject(EntityProjection.builder().type(ClassType.of(ConfigFile.class)).build(), + toId("models/tables/test.hjson", NO_VERSION), scope); + + assertNull(loaded); + } + + @Test + public void testMultipleFileOperations(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + String [] tables = {"table1", "table2", "table3"}; + + for (String tableName : tables) { + Supplier contentProvider = () -> String.format("{ \n" + + " tables: [{ \n" + + " name: %s\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}", tableName); + + ConfigFile newFile = ConfigFile.builder() + .type(TABLE) + .contentProvider(contentProvider) + .path(String.format("models/tables/%s.hjson", tableName)) + .build(); + + + tx.createObject(newFile, scope); + } + + ConfigFile invalid = ConfigFile.builder().path("/tmp").contentProvider((() -> "Invalid")).build(); + tx.createObject(invalid, scope); + + tx.delete(invalid, scope); + + tx.flush(scope); + tx.commit(scope); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + DataStoreIterable loaded = readTx.loadObjects(EntityProjection.builder() + .type(ClassType.of(ConfigFile.class)).build(), scope); + + List configFiles = Lists.newArrayList(loaded.iterator()); + + assertEquals(3, configFiles.size()); + + assertEquals("models/tables/table1.hjson", configFiles.get(0).getPath()); + assertEquals(TABLE, configFiles.get(0).getType()); + + assertEquals("models/tables/table2.hjson", configFiles.get(1).getPath()); + assertEquals(TABLE, configFiles.get(1).getType()); + + assertEquals("models/tables/table3.hjson", configFiles.get(2).getPath()); + assertEquals(TABLE, configFiles.get(2).getType()); + } + + protected ConfigFile createFile(String tableName, ConfigDataStore store, boolean validateOnly) { + Supplier contentProvider = () -> String.format("{ \n" + + " tables: [{ \n" + + " name: %s\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}", tableName); + + ConfigFile newFile = ConfigFile.builder() + .type(TABLE) + .contentProvider(contentProvider) + .path(String.format("models/tables/%s.hjson", tableName)) + .build(); + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + if (validateOnly) { + when(scope.getRequestHeaderByName(eq(VALIDATE_ONLY_HEADER))).thenReturn("true"); + } + + tx.createObject(newFile, scope); + tx.save(newFile, scope); + tx.flush(scope); + tx.commit(scope); + + return newFile; + } + + protected ConfigFile updateFile(String configRoot, ConfigDataStore store) { + Supplier contentProvider = () -> "{ \n" + + " tables: [{ \n" + + " name: Test2\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + ConfigFile updatedFile = ConfigFile.builder() + .type(TABLE) + .contentProvider(contentProvider) + .path("models/tables/test.hjson") + .build(); + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + tx.save(updatedFile, scope); + tx.flush(scope); + tx.commit(scope); + + return updatedFile; + } + + protected ConfigFile createInvalidFile(String configRoot, ConfigDataStore store) { + Supplier contentProvider = () -> "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INVALID_TYPE\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + ConfigFile newFile = ConfigFile.builder() + .type(TABLE) + .contentProvider(contentProvider) + .path("models/tables/test.hjson") + .build(); + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + tx.createObject(newFile, scope); + tx.save(newFile, scope); + tx.flush(scope); + tx.commit(scope); + + return newFile; + } + + protected boolean compare(ConfigFile a, ConfigFile b) { + return a.equals(b) && a.getContent().equals(b.getContent()) && a.getType().equals(b.getType()); + } + + protected boolean blockWrites(File file) { + Set perms = new HashSet<>(); + + try { + //Windows doesn't like this. + Files.setPosixFilePermissions(file.toPath(), perms); + } catch (Exception e) { + file.setWritable(false, false); + } + + return Files.isWritable(file.toPath()); + } +} diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidatorTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidatorTest.java new file mode 100644 index 0000000000..b964d62fb9 --- /dev/null +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidatorTest.java @@ -0,0 +1,482 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.validator; + +import static com.github.stefanbirkner.systemlambda.SystemLambda.catchSystemExit; +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemErr; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.modelconfig.model.Argument; +import com.yahoo.elide.modelconfig.model.Table; +import com.yahoo.elide.modelconfig.model.Type; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +public class DynamicConfigValidatorTest { + + @Test + public void testValidInheritanceConfig() throws Exception { + DynamicConfigValidator testClass = new DynamicConfigValidator(DefaultClassScanner.getInstance(), + "src/test/resources/validator/valid"); + + testClass.readConfigs(); + Table parent = testClass.getElideTableConfig().getTable("PlayerNamespace_PlayerStats"); + Table child = testClass.getElideTableConfig().getTable("PlayerNamespace_PlayerStatsChild"); + + // parent class dim + 3 new in child class + 2 overridden + assertEquals(parent.getDimensions().size(), 4); + assertEquals(child.getDimensions().size(), parent.getDimensions().size() + 3); + + // parent class measure + 1 new in child class + assertEquals(parent.getMeasures().size(), 2); + assertEquals(child.getMeasures().size(), parent.getMeasures().size() + 1); + + // parent class sql/table + assertEquals("player_stats", child.getTable()); + assertNull(child.getSql()); + assertEquals("gamedb", child.getSchema()); + assertNull(child.getDbConnectionName()); + assertTrue(child.getIsFact()); + assertEquals(2, child.getArguments().size()); + assertEquals(parent.getArguments(), child.getArguments()); + + // no new joins in child class, will inherit parent class joins + assertEquals(parent.getJoins().size(), child.getJoins().size()); + } + + @Test + public void testValidNamespace() throws Exception { + DynamicConfigValidator testClass = new DynamicConfigValidator(DefaultClassScanner.getInstance(), + "src/test/resources/validator/valid"); + testClass.readConfigs(); + Table parent = testClass.getElideTableConfig().getTable("PlayerNamespace_PlayerStats"); + Table child = testClass.getElideTableConfig().getTable("PlayerNamespace_PlayerStatsChild"); + Table referred = testClass.getElideTableConfig().getTable("Country"); + + assertEquals("PlayerNamespace", child.getNamespace()); + assertEquals("PlayerNamespace", parent.getNamespace()); + assertEquals("default", referred.getNamespace()); // Namespace in HJson "default". + } + + @Test + public void testHelpArgumnents() throws Exception { + tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "-h" })); + assertEquals(0, exitStatus); + }); + + tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--help" })); + assertEquals(0, exitStatus); + }); + } + + @Test + public void testNoArgumnents() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigValidator.main(null)); + assertEquals(1, exitStatus); + }); + + assertTrue(error.startsWith("Missing required option")); + } + + @Test + public void testOneEmptyArgument() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigValidator.main(new String[] { "" })); + assertEquals(1, exitStatus); + }); + + assertTrue(error.startsWith("Missing required option")); + } + + @Test + public void testMissingArgumentValue() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigValidator.main(new String[] { "--configDir" })); + assertEquals(2, exitStatus); + }); + + assertTrue(error.startsWith("Missing argument for option")); + + error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigValidator.main(new String[] { "-c" })); + assertEquals(2, exitStatus); + }); + + assertTrue(error.startsWith("Missing argument for option")); + } + + @Test + public void testMissingConfigDir() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/missing" })); + assertEquals(2, exitStatus); + }); + + assertTrue(error.contains("config path does not exist")); + } + + @Test + public void testValidConfigDir() throws Exception { + tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/valid"})); + assertEquals(0, exitStatus); + }); + } + + @Test + public void testMissingVariableConfig() throws Exception { + tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/missing_variable"})); + assertEquals(0, exitStatus); + }); + } + + @Test + public void testMissingSecurityConfig() throws Exception { + tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/missing_security"})); + assertEquals(0, exitStatus); + }); + } + + @Test + public void testMissingTableConfig() throws Exception { + tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/missing_table_config"})); + assertEquals(0, exitStatus); + }); + } + + @Test + public void testBadVariableConfig() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_variable"})); + assertEquals(2, exitStatus); + }); + + assertEquals("Invalid Hjson Syntax: Found '[' where a key name was " + + "expected (check your syntax or use quotes if the key name includes {}[],: or whitespace) at 3:7\n", + error); + } + + @Test + public void testInheritanceCycle() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_cyclic_inheritance" })); + assertEquals(2, exitStatus); + }); + + assertTrue(error.contains("Inheriting from table")); + assertTrue(error.contains("creates an illegal cyclic dependency.")); + } + + @Test + public void testMissingInheritanceModel() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/missing_inheritance" })); + assertEquals(2, exitStatus); + }); + + assertEquals("Undefined model: B is used as a Parent(extend) for another model.\n", error); + } + + @Test + public void testBadSecurityConfig() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_security"})); + assertEquals(2, exitStatus); + }); + + assertEquals("Invalid Hjson Syntax: Found '[' where a key name was expected " + + "(check your syntax or use quotes if the key name includes {}[],: or whitespace) at 3:11\n", + error); + } + + @Test + public void testDuplicateSecurityRoleConfig() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/duplicate_security_role"})); + assertEquals(2, exitStatus); + }); + + assertEquals("Duplicate!! Role name: 'prefab.role.all' is already defined. Please use different role.\n", error); + } + + @Test + public void testBadSecurityRoleConfig() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_security_role"})); + assertEquals(2, exitStatus); + }); + + String expectedError = "Schema validation failed for: models/security.hjson\n" + + "[ERROR]\n" + + "Instance[/roles/0] failed to validate against schema[/properties/roles/items]. Role [admin,] is not allowed. Role must start with an alphabetic character and can include alaphabets, numbers, spaces and '.' only.\n" + + "[ERROR]\n" + + "Instance[/roles/1] failed to validate against schema[/properties/roles/items]. Role [guest,] is not allowed. Role must start with an alphabetic character and can include alaphabets, numbers, spaces and '.' only.\n"; + assertEquals(expectedError, error); + } + + @Test + public void testBadSecurityChecks() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_security_check"})); + assertEquals(2, exitStatus); + }); + + assertEquals("Found undefined security checks: [guest, member, user]\n", error); + } + + @Test + public void testNamespaceBadDefaultName() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_default_namespace"})); + assertEquals(2, exitStatus); + }); + + String expected = "Schema validation failed for: models/namespaces/test_namespace.hjson\n" + + "[ERROR]\n" + + "Instance[/namespaces/0/name] failed to validate against schema[/properties/namespaces/items/properties/name]. Name [Default] clashes with the 'default' namespace. Either change the case or pick a different namespace name.\n"; + assertEquals(expected, error); + } + + @Test + public void testNamespaceBadSecurityChecks() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/namespace_bad_security_check"})); + assertEquals(2, exitStatus); + }); + + assertEquals("Found undefined security checks: [namespaceRead]\n", error); + } + + @Test + public void testMissingNamespace() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/missing_namespace"})); + assertEquals(2, exitStatus); + }); + + assertEquals("Namespace: TestNamespace is not included in dynamic configs\n", error); + } + + @Test + public void testBadTableConfigJoinType() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_table_join_type"})); + assertEquals(2, exitStatus); + }); + String expected = "Schema validation failed for: models/tables/table1.hjson\n" + + "[ERROR]\n" + + "Instance[/tables/0/joins/0/kind] failed to validate against schema[/definitions/join/properties/kind]. Join kind [toAll] is not allowed. Supported value is one of [ToOne, ToMany].\n" + + "[ERROR]\n" + + "Instance[/tables/0/joins/1/type] failed to validate against schema[/definitions/join/properties/type]. Join type [full outer] is not allowed. Supported value is one of [left, inner, full, cross].\n"; + + assertEquals(expected, error); + } + + @Test + public void testBadDimName() throws Exception { + String expectedMessage = "Schema validation failed for: models/tables/table1.hjson\n" + + "[ERROR]\n" + + "Instance[/tables/0/dimensions/0] failed to validate against schema[/properties/tables/items/properties/dimensions/items]. instance failed to match exactly one schema (matched 0 out of 2)\n" + + " Instance[/tables/0/dimensions/0] failed to validate against schema[/definitions/dimension]. instance failed to match all required schemas (matched only 1 out of 2)\n" + + " Instance[/tables/0/dimensions/0/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [id] is not allowed. Field name cannot be one of [id, sql]\n" + + " Instance[/tables/0/dimensions/0] failed to validate against schema[/definitions/timeDimension]. instance failed to match all required schemas (matched only 0 out of 2)\n" + + " Instance[/tables/0/dimensions/0/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [id] is not allowed. Field name cannot be one of [id, sql]\n" + + " Instance[/tables/0/dimensions/0/type] failed to validate against schema[/definitions/timeDimension/allOf/1/properties/type]. Field type [Text] is not allowed. Field type must be [Time] for any time dimension.\n" + + "[ERROR]\n" + + "Instance[/tables/0/dimensions/1] failed to validate against schema[/properties/tables/items/properties/dimensions/items]. instance failed to match exactly one schema (matched 0 out of 2)\n" + + " Instance[/tables/0/dimensions/1] failed to validate against schema[/definitions/dimension]. instance failed to match all required schemas (matched only 1 out of 2)\n" + + " Instance[/tables/0/dimensions/1/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [_region] is not allowed. Field name must start with lower case alphabet and can include alaphabets, numbers and '_' only.\n" + + " Instance[/tables/0/dimensions/1] failed to validate against schema[/definitions/timeDimension]. instance failed to match all required schemas (matched only 0 out of 2)\n" + + " Instance[/tables/0/dimensions/1/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [_region] is not allowed. Field name must start with lower case alphabet and can include alaphabets, numbers and '_' only.\n" + + " Instance[/tables/0/dimensions/1/type] failed to validate against schema[/definitions/timeDimension/allOf/1/properties/type]. Field type [Text] is not allowed. Field type must be [Time] for any time dimension.\n"; + + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_dim_name"})); + assertEquals(2, exitStatus); + }); + + assertEquals(expectedMessage, error); + } + + @Test + public void testBadTableConfigSQL() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_table_sql"})); + assertEquals(2, exitStatus); + }); + + assertTrue(error.startsWith("sql/definition provided in table config contain either ';' or one of these words")); + } + + @Test + public void testBadJoinModel() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> + DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_join_model"})); + assertEquals(2, exitStatus); + }); + assertTrue(error.contains(" is neither included in dynamic models nor in static models")); + } + + @Test + public void testUndefinedVariable() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigValidator + .main(new String[]{"--configDir", "src/test/resources/validator/undefined_handlebar"})); + + assertEquals(2, exitStatus); + }); + + assertEquals("foobar is used as a variable in either table or security config files " + + "but is not defined in variables config file.\n", error); + } + + public void testBadTableSource() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigValidator + .main(new String[]{"--configDir", "src/test/resources/validator/bad_tablesource"})); + + assertEquals(2, exitStatus); + }); + assertEquals("Invalid tableSource : Team.teamRegion . Field : teamRegion is undefined for hjson model: Team", + error); + } + + @Test + public void testDuplicateDBConfigName() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigValidator + .main(new String[]{"--configDir", "src/test/resources/validator/duplicate_dbconfigname"})); + + assertEquals(2, exitStatus); + }); + + //Java 11 introduces (and prints) the following deprecation warning: + //"Warning: Nashorn engine is planned to be removed from a future JDK release" + assertTrue(error.contains("Multiple DB configs found with the same name: OracleConnection\n")); + } + + @Test + public void testJoinedTablesDBConnectionNameMismatch() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigValidator + .main(new String[]{"--configDir", "src/test/resources/validator/mismatch_dbconfig"})); + + assertEquals(2, exitStatus); + }); + assertTrue(error.contains("DBConnection name mismatch between table: ")); + assertTrue(error.contains(" and tables in its Join Clause.")); + } + + @Test + public void testDuplicateArgumentName() throws Exception { + DynamicConfigValidator testClass = new DynamicConfigValidator(DefaultClassScanner.getInstance(), + "src/test/resources/validator/valid"); + testClass.readConfigs(); + Table playerStatsTable = testClass.getElideTableConfig().getTable("PlayerNamespace_PlayerStats"); + + // PlayerStats table already has argument 'countryCode' with type 'TEXT'. + // Adding another argument 'countryCode' with type 'INTEGER'. + playerStatsTable.getArguments().add(Argument.builder().name("countryCode").type(Type.INTEGER).build()); + Exception e = assertThrows(IllegalStateException.class, () -> testClass.validateConfigs()); + assertEquals("Multiple Arguments found with the same name: countryCode", e.getMessage()); + } + + @Test + public void testDuplicateArgumentNameInColumnFilter() throws Exception { + DynamicConfigValidator testClass = new DynamicConfigValidator(DefaultClassScanner.getInstance(), + "src/test/resources/validator/duplicate_column_args"); + testClass.readConfigs(); + + Exception e = assertThrows(IllegalStateException.class, () -> testClass.validateConfigs()); + assertEquals("Multiple Arguments found with the same name: foo", e.getMessage()); + } + + @Test + public void testDuplicateArgumentNameInTableFilter() throws Exception { + DynamicConfigValidator testClass = new DynamicConfigValidator(DefaultClassScanner.getInstance(), + "src/test/resources/validator/valid"); + testClass.readConfigs(); + Table playerStatsTable = testClass.getElideTableConfig().getTable("PlayerNamespace_PlayerStats"); + + // PlayerStats table already has a filter argument 'code' with type 'TEXT'. + playerStatsTable.getArguments().add(Argument.builder().name("code").type(Type.TEXT).build()); + Exception e = assertThrows(IllegalStateException.class, () -> testClass.validateConfigs()); + assertEquals("Multiple Arguments found with the same name: code", e.getMessage()); + } + + @Test + public void testDuplicateArgumentNameInComplexTableFilter() throws Exception { + DynamicConfigValidator testClass = new DynamicConfigValidator(DefaultClassScanner.getInstance(), + "src/test/resources/validator/valid"); + testClass.readConfigs(); + Table playerStatsTable = testClass.getElideTableConfig().getTable("PlayerNamespace_PlayerStats"); + + // PlayerStats table already has a filter argument 'code' with type 'TEXT'. + playerStatsTable.getArguments().add(Argument.builder().name("code").type(Type.TEXT).build()); + playerStatsTable.setFilterTemplate("foo=={{bar}};blah=={{code}}"); + Exception e = assertThrows(IllegalStateException.class, () -> testClass.validateConfigs()); + assertEquals("Multiple Arguments found with the same name: code", e.getMessage()); + } + + @Test + public void testPathAndConfigFileTypeMismatches() { + DynamicConfigValidator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), + "src/test/resources/validator/valid"); + + Map resources = Map.of("blah/foo", + ConfigFile.builder().path("blah/foo").type(ConfigFile.ConfigFileType.SECURITY).build()); + + assertThrows(BadRequestException.class, () -> validator.validate(resources)); + + Map resources2 = Map.of("models/variables.hjson", + ConfigFile.builder().path("models/variables.hjson").type(ConfigFile.ConfigFileType.TABLE).build()); + + assertThrows(BadRequestException.class, () -> validator.validate(resources2)); + + Map resources3 = Map.of("models/tables/referred_model.hjson", + ConfigFile.builder().path("models/tables/referred_model.hjson").type(ConfigFile.ConfigFileType.NAMESPACE).build()); + + assertThrows(BadRequestException.class, () -> validator.validate(resources3)); + + Map resources4 = Map.of("models/tables/referred_model.hjson", + ConfigFile.builder().path("models/tables/referred_model.hjson").type(ConfigFile.ConfigFileType.DATABASE).build()); + + assertThrows(BadRequestException.class, () -> validator.validate(resources4)); + } +} diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/verify/DynamicConfigVerifierTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/verify/DynamicConfigVerifierTest.java new file mode 100644 index 0000000000..1b26b7336a --- /dev/null +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/verify/DynamicConfigVerifierTest.java @@ -0,0 +1,156 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.modelconfig.verify; + +import static com.github.stefanbirkner.systemlambda.SystemLambda.catchSystemExit; +import static com.github.stefanbirkner.systemlambda.SystemLambda.tapSystemErr; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; +import org.apache.commons.compress.utils.IOUtils; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.util.Base64; + +public class DynamicConfigVerifierTest { + + private static KeyPair kp; + private static String signature; + private static String tarContent = null; + private static final String TAR_FILE_PATH = "src/test/resources/configs.tar.gz"; + + @BeforeAll + public static void setUp() throws Exception { + createTarGZ(); + kp = generateKeyPair(); + tarContent = DynamicConfigVerifier.readTarContents(TAR_FILE_PATH); + signature = sign(tarContent, kp.getPrivate()); + } + + @AfterAll + public static void after() { + FileUtils.deleteQuietly(FileUtils.getFile(TAR_FILE_PATH)); + } + + @Test + public void testValidSignature() throws Exception { + assertTrue(DynamicConfigVerifier.verify(tarContent, signature, kp.getPublic())); + } + + @Test + public void testInvalidSignature() throws Exception { + assertFalse(DynamicConfigVerifier.verify("invalid-signature", signature, kp.getPublic())); + } + + @Test + public void testHelpArguments() { + assertDoesNotThrow(() -> DynamicConfigVerifier.main(new String[] { "-h" })); + assertDoesNotThrow(() -> DynamicConfigVerifier.main(new String[] { "--help" })); + } + + @Test + public void testNoArguments() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigVerifier.main(null)); + assertEquals(1, exitStatus); + }); + + assertTrue(error.startsWith("Missing required option")); + } + + @Test + public void testOneEmptyArguments() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigVerifier.main(new String[] { "" })); + assertEquals(1, exitStatus); + }); + + assertTrue(error.startsWith("Missing required option")); + } + + @Test + public void testMissingArgumentValue() throws Exception { + String error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigVerifier.main(new String[] { "--tarFile" })); + assertEquals(3, exitStatus); + }); + + assertTrue(error.startsWith("Missing argument for option")); + + error = tapSystemErr(() -> { + int exitStatus = catchSystemExit(() -> DynamicConfigVerifier.main(new String[] { "-t" })); + assertEquals(3, exitStatus); + }); + + assertTrue(error.startsWith("Missing argument for option")); + } + + private static KeyPair generateKeyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048, new SecureRandom()); + return generator.generateKeyPair(); + } + + private static String sign(String data, PrivateKey privateKey) throws Exception { + Signature privateSignature = Signature.getInstance("SHA256withRSA"); + privateSignature.initSign(privateKey); + privateSignature.update(data.getBytes(StandardCharsets.UTF_8)); + byte[] signature = privateSignature.sign(); + return Base64.getEncoder().encodeToString(signature); + } + + private static void createTarGZ() throws FileNotFoundException, IOException { + TarArchiveOutputStream tarOutputStream = null; + try { + String configPath = "src/test/resources/configs/"; + tarOutputStream = new TarArchiveOutputStream(new GzipCompressorOutputStream( + new BufferedOutputStream(new FileOutputStream(new File(TAR_FILE_PATH))))); + addFileToTarGz(tarOutputStream, configPath, ""); + } finally { + tarOutputStream.finish(); + tarOutputStream.close(); + } + } + + private static void addFileToTarGz(TarArchiveOutputStream tOut, String path, String base) throws IOException { + File f = new File(path); + String entryName = base + f.getName(); + TarArchiveEntry tarEntry = new TarArchiveEntry(f, entryName); + tOut.putArchiveEntry(tarEntry); + + if (f.isFile()) { + IOUtils.copy(new FileInputStream(f), tOut); + tOut.closeArchiveEntry(); + } else { + tOut.closeArchiveEntry(); + File[] children = f.listFiles(); + if (children != null) { + for (File child : children) { + addFileToTarGz(tOut, child.getAbsolutePath(), entryName + "/"); + } + } + } + } +} diff --git a/elide-model-config/src/test/resources/logback.xml b/elide-model-config/src/test/resources/logback.xml new file mode 100644 index 0000000000..5880836a3b --- /dev/null +++ b/elide-model-config/src/test/resources/logback.xml @@ -0,0 +1,16 @@ + + + + + + %msg%n + + + + + + diff --git a/elide-model-config/src/test/resources/validator/bad_cyclic_inheritance/models/tables/tableA.hjson b/elide-model-config/src/test/resources/validator/bad_cyclic_inheritance/models/tables/tableA.hjson new file mode 100644 index 0000000000..02e07ddc69 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_cyclic_inheritance/models/tables/tableA.hjson @@ -0,0 +1,18 @@ +{ + tables: + [ + { + name: A + extend: B + description: A extends B (which extends A) - an invalid cycle. + measures : [ + { + name : count + type : Integer + description : A simple count + definition: 'COUNT(*)' + } + ] + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/bad_cyclic_inheritance/models/tables/tableB.hjson b/elide-model-config/src/test/resources/validator/bad_cyclic_inheritance/models/tables/tableB.hjson new file mode 100644 index 0000000000..e6e0d67a64 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_cyclic_inheritance/models/tables/tableB.hjson @@ -0,0 +1,18 @@ +{ + tables: + [ + { + name: B + extend: A + description: B extends A (which extends B) - an invalid cycle. + measures : [ + { + name : count + type : Integer + description : A simple count + definition: 'COUNT(*)' + } + ] + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/bad_default_namespace/models/namespaces/test_namespace.hjson b/elide-model-config/src/test/resources/validator/bad_default_namespace/models/namespaces/test_namespace.hjson new file mode 100644 index 0000000000..c8f346d7ad --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_default_namespace/models/namespaces/test_namespace.hjson @@ -0,0 +1,10 @@ +{ + namespaces: + [ + { + name: Default + description: Namespace for Test + friendlyName: Default + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/bad_default_namespace/models/tables/namespace_test_model.hjson b/elide-model-config/src/test/resources/validator/bad_default_namespace/models/tables/namespace_test_model.hjson new file mode 100644 index 0000000000..18b3a48769 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_default_namespace/models/tables/namespace_test_model.hjson @@ -0,0 +1,28 @@ +{ + tables: [{ + name: NameSpaceTestModel +## default namespace + table: test + category: Table Category + cardinality : lARge + hidden : false + + measures : [ + { + name : testMeasure + friendlyName : Test Measure + type : INteGER + description : Test Measure + definition: 'MAX({{measure}})' + tags: ['PUBLIC'] + } + ] + dimensions : [ + { + name : datetime + type : TIme + definition : '{{datetime}}' + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/bad_dim_name/models/tables/table1.hjson b/elide-model-config/src/test/resources/validator/bad_dim_name/models/tables/table1.hjson new file mode 100644 index 0000000000..7d47c31f05 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_dim_name/models/tables/table1.hjson @@ -0,0 +1,23 @@ +{ + tables: [ + { + name: Country + table: country + cardinality: Small + dimensions: + [ + { + name: id + type: Text + definition: '{{id}}' + } + { + // unsupported: name starts with '_' + name: _region + type: Text + definition: '{{region}}' + } + ] + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/bad_join_model/models/tables/table1.hjson b/elide-model-config/src/test/resources/validator/bad_join_model/models/tables/table1.hjson new file mode 100644 index 0000000000..ae0feded83 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_join_model/models/tables/table1.hjson @@ -0,0 +1,26 @@ +{ + tables: [{ + name: PlayerStats + sql: PlayerStats + schema: gamedb + cardinality : large + hidden : false + readAccess : A user is admin or is a player in the game + joins: [ + { + name: playerCountry + // undefined model PlayerContinent + to: PlayerContinent + kind: toOne + definition: '{{id}} = {{playerCountry.country_id}}' + } + ] + dimensions : [ + { + name : countryIsoCode + type : TEXT + definition : '{{playerCountry.isoCode}}' + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/bad_security/models/security.hjson b/elide-model-config/src/test/resources/validator/bad_security/models/security.hjson new file mode 100644 index 0000000000..7c916b3847 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_security/models/security.hjson @@ -0,0 +1,19 @@ +{ + // colon missing after roles + roles [ + admin + guest + dev + ] + rules: [ + { + type: filter + filter: filter + name: User belongs to company + }, + { + filter: filter + name: Principal is owner + }, + ] +} diff --git a/elide-model-config/src/test/resources/validator/bad_security_check/models/tables/table1.hjson b/elide-model-config/src/test/resources/validator/bad_security_check/models/tables/table1.hjson new file mode 100644 index 0000000000..56312824a1 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_security_check/models/tables/table1.hjson @@ -0,0 +1,23 @@ +{ + tables: [{ + name: PlayerStats + table: PlayerStats + // undefined security role: guest + readAccess : guest + dimensions : [ + { + name : name + type : TEXT + definition : '{{name}}' + // undefined security roles: user & member + readAccess : user AND member + }, + { + name : region + type : TEXT + definition : '{{region}}' + readAccess : Prefab.Role.None + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/bad_security_role/models/security.hjson b/elide-model-config/src/test/resources/validator/bad_security_role/models/security.hjson new file mode 100644 index 0000000000..cda5836658 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_security_role/models/security.hjson @@ -0,0 +1,19 @@ +{ + // comma after admin is wrong, admin should be in quotes if using comma + roles: [ + admin, + guest, + dev + ] + rules: [ + { + type: filter + filter: filter + name: User belongs to company + }, + { + filter: filter + name: Principal is owner + }, + ] +} diff --git a/elide-model-config/src/test/resources/validator/bad_table_join_type/models/tables/table1.hjson b/elide-model-config/src/test/resources/validator/bad_table_join_type/models/tables/table1.hjson new file mode 100644 index 0000000000..ebf05dd975 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_table_join_type/models/tables/table1.hjson @@ -0,0 +1,58 @@ +{ + 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 + // Invalid Join Kind + kind: toAll + definition: '{{playerCountry.id}} = {{country_id}}' + }, + { + name: playerTeam + to: team + kind: toMany + // Invalid Join Type + type: full outer + definition: '{{playerTeam.id}} = {{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: + [{ + type : DAY + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/bad_table_sql/models/tables/table1.hjson b/elide-model-config/src/test/resources/validator/bad_table_sql/models/tables/table1.hjson new file mode 100644 index 0000000000..301b0ec04c --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_table_sql/models/tables/table1.hjson @@ -0,0 +1,56 @@ +{ + tables: [{ + name: PlayerStats + // Sql has disallowed words + sql: Truncate Table PlayerStats + 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 + kind: toOne + definition: '{{playerCountry.id}} = {{country_id}}' + }, + { + name: playerTeam + to: team + kind: toMany + definition: '{{playerTeam.id}} = {{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: + [{ + type : DAY + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/bad_tablesource/models/tables/table1.hjson b/elide-model-config/src/test/resources/validator/bad_tablesource/models/tables/table1.hjson new file mode 100644 index 0000000000..eb16d4f8cc --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_tablesource/models/tables/table1.hjson @@ -0,0 +1,35 @@ +{ + tables: [{ + name: PlayerStats + table: player_stats + joins: [ + { + name: playerTeam + to: Team + kind: toMany + definition: '{{playerTeam.id}} = {{ team_id}}' + } + ] + dimensions : [ + { + name : teamRegion + type : TEXT + definition : '{{playerTeam.region}}' + // undefined column teamRegion + tableSource: Team.teamRegion + } + ] + } + { + name: Team + table: team + cardinality: small + dimensions: [ + { + name: region + type: TEXT + definition: '{{region}}' + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/bad_variable/models/variables.hjson b/elide-model-config/src/test/resources/validator/bad_variable/models/variables.hjson new file mode 100644 index 0000000000..a4cd13c9d7 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/bad_variable/models/variables.hjson @@ -0,0 +1,4 @@ +{ + // Missing colon + foo [1, 2, 3] +} diff --git a/elide-model-config/src/test/resources/validator/duplicate_column_args/models/tables/table.hjson b/elide-model-config/src/test/resources/validator/duplicate_column_args/models/tables/table.hjson new file mode 100644 index 0000000000..8a6e6952a1 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/duplicate_column_args/models/tables/table.hjson @@ -0,0 +1,28 @@ +{ + tables: [{ + name: Player + table: player + schema: playerdb + description: + ''' + A long description + ''' + cardinality : large + measures : [ + { + name : highScore + type : "INTEGER" + definition: 'MAX({{score}})' + //'foo' here conflicts with the argument 'foo' below. + filterTemplate: highScore>{{foo}} + arguments: [ + { + name: foo + type: TEXT + default: foobar + } + ] + }] + dimensions: [] + }] +} diff --git a/elide-model-config/src/test/resources/validator/duplicate_dbconfigname/db/sql/db1.hjson b/elide-model-config/src/test/resources/validator/duplicate_dbconfigname/db/sql/db1.hjson new file mode 100644 index 0000000000..15821c1e51 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/duplicate_dbconfigname/db/sql/db1.hjson @@ -0,0 +1,12 @@ +{ + dbconfigs: + [ + { + name: OracleConnection + url: jdbc:oracle:thin:@localhost:1521:testdb + driver: oracle.jdbc.driver.OracleDriver + user: guestoracle + dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.H2Dialect + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/duplicate_dbconfigname/db/sql/db2.hjson b/elide-model-config/src/test/resources/validator/duplicate_dbconfigname/db/sql/db2.hjson new file mode 100644 index 0000000000..22734b2646 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/duplicate_dbconfigname/db/sql/db2.hjson @@ -0,0 +1,15 @@ +{ + dbconfigs: + [ + { + name: OracleConnection + url: jdbc:oracle:thin:@localhost:1521:testdb + driver: oracle.jdbc.driver.OracleDriver + user: guestoracle + dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.H2Dialect + "propertyMap": { + "hibernate.show_sql": true, + } + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/duplicate_security_role/models/security.hjson b/elide-model-config/src/test/resources/validator/duplicate_security_role/models/security.hjson new file mode 100644 index 0000000000..3b2d15b9ce --- /dev/null +++ b/elide-model-config/src/test/resources/validator/duplicate_security_role/models/security.hjson @@ -0,0 +1,6 @@ +{ + roles: [ + Admin User + prefab.role.all + ] +} diff --git a/elide-model-config/src/test/resources/validator/invalid_schema/db_invalid.hjson b/elide-model-config/src/test/resources/validator/invalid_schema/db_invalid.hjson new file mode 100644 index 0000000000..683e1a64a7 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/invalid_schema/db_invalid.hjson @@ -0,0 +1,25 @@ +{ + dbconfigs: + [ + { + name: 11MyDB2Connection + url: jdbc:db2:localhost:50000/testdb + driver: 11COM.ibm.db2.jdbc.net.DB2Driver + user: guestdb2 + dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.PrestoDBDialec + propertyMap: + { + hibernate.show_sql: null + hibernate.default_batch_fetch_size: 100.1 + hibernate.hbm2ddl.auto: create + } + } + { + name: MySQLConnection + url: ojdbc:mysql://localhost/testdb?serverTimezone=UTC + driver: com.mysql.jdbc.Driver + user: guestmysql + dialect: 1234 + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/invalid_schema/invalid_dimension_data_source.hjson b/elide-model-config/src/test/resources/validator/invalid_schema/invalid_dimension_data_source.hjson new file mode 100644 index 0000000000..7789322115 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/invalid_schema/invalid_dimension_data_source.hjson @@ -0,0 +1,61 @@ +{ + tables: [{ + name: PlayerStats + table: player_stats + schema: gamedb + description: + ''' + A long description + ''' + category: Table Category + cardinality : large + hidden : false + readAccess : A user is admin or is a player in the game + filterTemplate : countryIsoCode=={{code}} + joins: [ + { + name: playerCountry + to: country + kind: toOne + definition: '{{playerCountry.id}} = {{country_id}}' + }, + { + name: playerTeam + to: team + kind: toMany + definition: '{{playerTeam.id}} = {{team_id}}' + } + ] + + measures : [ + { + name : highScore + type : INTEGER + description : very awesome score + definition: 'MAX({{score}})' + } + ] + dimensions : [ + { + name : countryIsoCode + type : TEXT + category : country detail + definition : '{{playerCountry.isoCode}}' + values : ['US', 'HK'] + tableSource : 'playerCountry.isoCode' + } + { + name : createdOn + type : TIME + definition : '{{create_on}}' + grains: + [{ + type : DAY + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/invalid_schema/invalid_query_plan_classname.hjson b/elide-model-config/src/test/resources/validator/invalid_schema/invalid_query_plan_classname.hjson new file mode 100644 index 0000000000..af6d1baaae --- /dev/null +++ b/elide-model-config/src/test/resources/validator/invalid_schema/invalid_query_plan_classname.hjson @@ -0,0 +1,64 @@ +{ + tables: [{ + name: PlayerStats + table: player_stats + schema: gamedb + description: + ''' + A long description + ''' + category: Table Category + cardinality : large + hidden : false + readAccess : A user is admin or is a player in the game + filterTemplate : countryIsoCode=={{code}} + tags: ['GAME', 'PLAYER'] + joins: [ + { + name: playerCountry + to: country + kind: toOne + definition: '${to}.id = ${from}.country_id' + }, + { + name: playerTeam + to: team + kind: toMany + definition: '${to}.id = ${from}.team_id' + } + ] + + measures : [ + { + name : highScore + type : INTEGER + description : very awesome score + definition: 'MAX({{score}})' + maker: 'a.b' + tags: ['PUBLIC'] + } + ] + dimensions : [ + { + name : countryIsoCode + type : TEXT + category : country detail + definition : '{{playerCountry.isoCode}}' + values : ['US', 'HK'] + tags: ['PRIVATE'] + }, + { + name : createdOn + type : TIME + definition : '{{create_on}}' + grains: + [{ + type : DAY + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/invalid_schema/invalid_table_filter.hjson b/elide-model-config/src/test/resources/validator/invalid_schema/invalid_table_filter.hjson new file mode 100644 index 0000000000..85bfada8f0 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/invalid_schema/invalid_table_filter.hjson @@ -0,0 +1,61 @@ +{ + tables: [{ + name: PlayerStats + table: player_stats + schema: gamedb + description: + ''' + A long description + ''' + category: Table Category + cardinality : large + hidden : false + readAccess : A user is admin or is a player in the game + filterTemplate : countryIsoCode={{code} #Invalid Operator + joins: [ + { + name: playerCountry + to: country + kind: toOne + definition: '{{playerCountry.id}} = {{country_id}}' + }, + { + name: playerTeam + to: team + kind: toMany + definition: '{{playerTeam.id}} = {{team_id}}' + } + ] + + measures : [ + { + name : highScore + type : INTEGER + description : very awesome score + definition: 'MAX({{score}})' + } + ] + dimensions : [ + { + name : countryIsoCode + type : TEXT + category : country detail + definition : '{{playerCountry.isoCode}}' + values : ['US', 'HK'] + tableSource : 'playerCountry.isoCode' + } + { + name : createdOn + type : TIME + definition : '{{create_on}}' + grains: + [{ + type : DAY + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/invalid_schema/security_invalid.hjson b/elide-model-config/src/test/resources/validator/invalid_schema/security_invalid.hjson new file mode 100644 index 0000000000..d166716f97 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/invalid_schema/security_invalid.hjson @@ -0,0 +1,11 @@ +{ + //Comment + name : book + table : book + schema$ : [123] + description : + ''' + valid schema for a book + ''' + cardinality : small +} diff --git a/elide-model-config/src/test/resources/validator/invalid_schema/table_invalid.hjson b/elide-model-config/src/test/resources/validator/invalid_schema/table_invalid.hjson new file mode 100644 index 0000000000..d166716f97 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/invalid_schema/table_invalid.hjson @@ -0,0 +1,11 @@ +{ + //Comment + name : book + table : book + schema$ : [123] + description : + ''' + valid schema for a book + ''' + cardinality : small +} diff --git a/elide-model-config/src/test/resources/validator/invalid_schema/table_schema_with_multiple_errors.hjson b/elide-model-config/src/test/resources/validator/invalid_schema/table_schema_with_multiple_errors.hjson new file mode 100644 index 0000000000..6da4c945d7 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/invalid_schema/table_schema_with_multiple_errors.hjson @@ -0,0 +1,110 @@ +{ + // unsupported: additional property 'name' + name: Geography + tables: + [ + { + // unsupported: name contains '@' + name: Country@10 + table: country + // unsupported: cardinality value + cardinality: Extra Large + filterTemplate: countryIsoCode={{code}};startTime=={{start}} + arguments: + [ + { + name: PI + // unsupported: argument type + type: Number + } + { + // unsupported: grain is not allowed + name: Grain + type: Boolean + } + { + // both tableSource and values are not supported. Choose One or None + name: aggregation + description: Aggregation + type : TEXT + values: ['SUM', 'MIN', 'MAX'] + tableSource: { + table: abc + column: def + } + default: SUM + } + ] + measures: + [ + { + name : highScore + type : INTEGER + description : very awesome score + definition: 'MAX({{score}})' + // unsupported: class name (@ is not allowed) + maker: 'com.yahoo.elide.datastores.aggregation.query@DefaultMetricProjectionMaker.class' + tags: ['PUBLIC'] + } + ] + dimensions: + [ + { + // unsupported: field name is 'id' + name: id + // unsupported: field type + type: Float + definition: "{{id}}" + // unsupported: cardinality value + cardinality: Extra small + tableSource: { + table: abc + column: def + } + } + { + // unsupported: field name starts with '_' + name: _region + type: Text + definition: "{{region}}" + // unsupported: either values or table source is allowed not both + values: + [ + US + HK + ] + tableSource: { + table: abc + column: def + } + // unsupported: tags should be array + tags: PRIVATE + } + { + name: createdOn + // unsupported field type + type: TIMEX + definition: "{{create_on}}" + grains: + [{ + // unsupported grain type + type: Days + }] + } + { + name: updatedOn + type: Time + definition: "{{updated_on}}" + grains: [{ + type: month + }] + } + { + name: modifiedOn + type: Time + definition: "{{modified_on}}" + } + ] + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/invalid_schema/variables_invalid.hjson b/elide-model-config/src/test/resources/validator/invalid_schema/variables_invalid.hjson new file mode 100644 index 0000000000..bf620b8f90 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/invalid_schema/variables_invalid.hjson @@ -0,0 +1,11 @@ +{ + //Comment + name : book + table : book + schema$ : [123] + description : + ''' + valid schema for a book + ''' + cardinality : null +} diff --git a/elide-model-config/src/test/resources/validator/mismatch_dbconfig/models/tables/table1.hjson b/elide-model-config/src/test/resources/validator/mismatch_dbconfig/models/tables/table1.hjson new file mode 100644 index 0000000000..231264fe49 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/mismatch_dbconfig/models/tables/table1.hjson @@ -0,0 +1,64 @@ +{ + tables: [{ + name: PlayerStats + table: player_stats + schema: gamedb + dbConnectionName: MySQLConnection + description: + ''' + A long description + ''' + cardinality : large + hidden : false + joins: [ + { + name: playerCountry + to: Country + kind: toOne + definition: '{{playerCountry.id}} = {{country_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: + [{ + type : DAY + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + } + ] + } + { + name: Country + table: country + // connection name not same as parent table + dbConnectionName: OracleConnection + dimensions: + [ + { + name: countryName + type: TEXT + definition: '{{countryName}}' + } + ] + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/missing_configs/models/tables/.gitignore b/elide-model-config/src/test/resources/validator/missing_configs/models/tables/.gitignore new file mode 100644 index 0000000000..e59dea8c0b --- /dev/null +++ b/elide-model-config/src/test/resources/validator/missing_configs/models/tables/.gitignore @@ -0,0 +1 @@ +Empty Directory diff --git a/elide-model-config/src/test/resources/validator/missing_inheritance/models/tables/tableA.hjson b/elide-model-config/src/test/resources/validator/missing_inheritance/models/tables/tableA.hjson new file mode 100644 index 0000000000..6c18efa5ab --- /dev/null +++ b/elide-model-config/src/test/resources/validator/missing_inheritance/models/tables/tableA.hjson @@ -0,0 +1,18 @@ +{ + tables: + [ + { + name: A + extend: B + description: A extends B (which doesn't exist) + measures : [ + { + name : count + type : Integer + description : A simple count + definition: 'COUNT(*)' + } + ] + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/missing_namespace/models/tables/namespace_test_model.hjson b/elide-model-config/src/test/resources/validator/missing_namespace/models/tables/namespace_test_model.hjson new file mode 100644 index 0000000000..d6af68a52a --- /dev/null +++ b/elide-model-config/src/test/resources/validator/missing_namespace/models/tables/namespace_test_model.hjson @@ -0,0 +1,28 @@ +{ + tables: [{ + name: NameSpaceTestModel + namespace: TestNamespace + table: test + category: Table Category + cardinality : lARge + hidden : false + + measures : [ + { + name : testMeasure + friendlyName : Test Measure + type : INteGER + description : Test Measure + definition: 'MAX({{measure}})' + tags: ['PUBLIC'] + } + ] + dimensions : [ + { + name : datetime + type : TIme + definition : '{{datetime}}' + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/missing_security/models/tables/table2.hjson b/elide-model-config/src/test/resources/validator/missing_security/models/tables/table2.hjson new file mode 100644 index 0000000000..220881b497 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/missing_security/models/tables/table2.hjson @@ -0,0 +1,34 @@ +{ + // should work fine if missing security.hjson not provided. + tables: [{ + name: Player + table: player + schema: playerdb + description: + ''' + A long description + ''' + cardinality : large + measures : [ + { + name : highScore + type : "<%measure_type%>" + definition: 'MAX({{score}})' + } + ] + dimensions : [ + { + name : createdOn + type : TIME + definition : '{{create_on}}' + grains: + [{ + type : DAY + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/missing_security/models/variables.hjson b/elide-model-config/src/test/resources/validator/missing_security/models/variables.hjson new file mode 100644 index 0000000000..e7316ff732 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/missing_security/models/variables.hjson @@ -0,0 +1,9 @@ +{ + foo: [1, 2, 3] + bar: blah + hour: hour_replace + measure_type: INTEGER + name: PlayerStats + table: player_stats + role: member +} diff --git a/elide-model-config/src/test/resources/validator/missing_table_config/db/sql/db2.hjson b/elide-model-config/src/test/resources/validator/missing_table_config/db/sql/db2.hjson new file mode 100644 index 0000000000..15821c1e51 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/missing_table_config/db/sql/db2.hjson @@ -0,0 +1,12 @@ +{ + dbconfigs: + [ + { + name: OracleConnection + url: jdbc:oracle:thin:@localhost:1521:testdb + driver: oracle.jdbc.driver.OracleDriver + user: guestoracle + dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.H2Dialect + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/missing_table_config/models/variables.hjson b/elide-model-config/src/test/resources/validator/missing_table_config/models/variables.hjson new file mode 100644 index 0000000000..e7316ff732 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/missing_table_config/models/variables.hjson @@ -0,0 +1,9 @@ +{ + foo: [1, 2, 3] + bar: blah + hour: hour_replace + measure_type: INTEGER + name: PlayerStats + table: player_stats + role: member +} diff --git a/elide-model-config/src/test/resources/validator/missing_variable/models/security.hjson b/elide-model-config/src/test/resources/validator/missing_variable/models/security.hjson new file mode 100644 index 0000000000..31a87c3135 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/missing_variable/models/security.hjson @@ -0,0 +1,18 @@ +{ + roles : [ + admin + guest + dev + ] + rules: [ + { + type: filter + filter: filter + name: User belongs to company + }, + { + filter: filter + name: Principal is owner + }, + ] +} diff --git a/elide-model-config/src/test/resources/validator/missing_variable/models/tables/table2.hjson b/elide-model-config/src/test/resources/validator/missing_variable/models/tables/table2.hjson new file mode 100644 index 0000000000..d0992dc6b0 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/missing_variable/models/tables/table2.hjson @@ -0,0 +1,34 @@ +{ + // should work fine if missing variables.hjson not provided. + tables: [{ + name: Player + table: player + schema: playerdb + description: + ''' + A long description + ''' + cardinality : large + measures : [ + { + name : highScore + type : "INTEGER" + definition: 'MAX({{score}})' + } + ] + dimensions : [ + { + name : createdOn + type : TIME + definition : '{{create_on}}' + grains: + [{ + type : DAY + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/namespace_bad_security_check/models/namespaces/test_namespace.hjson b/elide-model-config/src/test/resources/validator/namespace_bad_security_check/models/namespaces/test_namespace.hjson new file mode 100644 index 0000000000..3d4e2e5bd5 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/namespace_bad_security_check/models/namespaces/test_namespace.hjson @@ -0,0 +1,11 @@ +{ + namespaces: + [ + { + name: TestNamespace + description: Namespace for Test + friendlyName: TestNamespace + readAccess: namespaceRead + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/namespace_bad_security_check/models/tables/namespace_test_model.hjson b/elide-model-config/src/test/resources/validator/namespace_bad_security_check/models/tables/namespace_test_model.hjson new file mode 100644 index 0000000000..d6af68a52a --- /dev/null +++ b/elide-model-config/src/test/resources/validator/namespace_bad_security_check/models/tables/namespace_test_model.hjson @@ -0,0 +1,28 @@ +{ + tables: [{ + name: NameSpaceTestModel + namespace: TestNamespace + table: test + category: Table Category + cardinality : lARge + hidden : false + + measures : [ + { + name : testMeasure + friendlyName : Test Measure + type : INteGER + description : Test Measure + definition: 'MAX({{measure}})' + tags: ['PUBLIC'] + } + ] + dimensions : [ + { + name : datetime + type : TIme + definition : '{{datetime}}' + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/undefined_handlebar/models/security.hjson b/elide-model-config/src/test/resources/validator/undefined_handlebar/models/security.hjson new file mode 100644 index 0000000000..158c841975 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/undefined_handlebar/models/security.hjson @@ -0,0 +1,19 @@ +{ + roles : [ + admin + guest + // foobar is undefined + <% foobar %> + ] + rules: [ + { + type: filter + filter: filter + name: User belongs to company + }, + { + filter: filter + name: Principal is owner + }, + ] +} diff --git a/elide-model-config/src/test/resources/validator/undefined_handlebar/models/variables.hjson b/elide-model-config/src/test/resources/validator/undefined_handlebar/models/variables.hjson new file mode 100644 index 0000000000..6fe582b908 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/undefined_handlebar/models/variables.hjson @@ -0,0 +1,3 @@ +{ + foo: [1, 2, 3] +} diff --git a/elide-model-config/src/test/resources/validator/valid/db/sql/multiple_db.hjson b/elide-model-config/src/test/resources/validator/valid/db/sql/multiple_db.hjson new file mode 100644 index 0000000000..8bea0682d8 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/db/sql/multiple_db.hjson @@ -0,0 +1,25 @@ +{ + dbconfigs: + [ + { + name: MyDB2Connection + url: jdbc:db2:localhost:50000/testdb + driver: COM.ibm.db2.jdbc.net.DB2Driver + user: guestdb2 + dialect: <% db2_dialect %> + propertyMap: + { + hibernate.show_sql: true + hibernate.default_batch_fetch_size: 100.1 + hibernate.hbm2ddl.auto: create + } + } + { + name: MySQLConnection + url: jdbc:mysql://localhost/testdb?serverTimezone=UTC + driver: com.mysql.jdbc.Driver + user: guestmysql + dialect: <%mysql_dialect%> + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/valid/db/sql/multiple_db_no_variables.hjson b/elide-model-config/src/test/resources/validator/valid/db/sql/multiple_db_no_variables.hjson new file mode 100644 index 0000000000..ea04948623 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/db/sql/multiple_db_no_variables.hjson @@ -0,0 +1,25 @@ +{ + dbconfigs: + [ + { + name: MyDB2Connection + url: jdbc:db2:localhost:50000/testdb + driver: COM.ibm.db2.jdbc.net.DB2Driver + user: guestdb2 + dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.PrestoDBDialect + propertyMap: + { + hibernate.show_sql: true + hibernate.default_batch_fetch_size: 100.1 + hibernate.hbm2ddl.auto: create + } + } + { + name: MySQLConnection + url: jdbc:mysql://localhost/testdb?serverTimezone=UTC + driver: com.mysql.jdbc.Driver + user: guestmysql + dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.HiveDialect + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/valid/db/sql/single_db.hjson b/elide-model-config/src/test/resources/validator/valid/db/sql/single_db.hjson new file mode 100644 index 0000000000..15821c1e51 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/db/sql/single_db.hjson @@ -0,0 +1,12 @@ +{ + dbconfigs: + [ + { + name: OracleConnection + url: jdbc:oracle:thin:@localhost:1521:testdb + driver: oracle.jdbc.driver.OracleDriver + user: guestoracle + dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.H2Dialect + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/valid/db/variables.hjson b/elide-model-config/src/test/resources/validator/valid/db/variables.hjson new file mode 100644 index 0000000000..cebe194634 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/db/variables.hjson @@ -0,0 +1,4 @@ +{ + mysql_dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.HiveDialect + db2_dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.PrestoDBDialect +} diff --git a/elide-model-config/src/test/resources/validator/valid/models/namespaces/player.hjson b/elide-model-config/src/test/resources/validator/valid/models/namespaces/player.hjson new file mode 100644 index 0000000000..312874ca77 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/models/namespaces/player.hjson @@ -0,0 +1,10 @@ +{ + namespaces: + [ + { + name: PlayerNamespace + description: Namespace for Player Schema Tables + friendlyName: Player + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/valid/models/security.hjson b/elide-model-config/src/test/resources/validator/valid/models/security.hjson new file mode 100644 index 0000000000..8ded2b9020 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/models/security.hjson @@ -0,0 +1,19 @@ +{ + roles : [ + admin + gu.est user + member + user + ] + rules: [ + { + type: filter + filter: filter + name: User belongs to company + }, + { + filter: filter + name: Principal is owner + }, + ] +} diff --git a/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats.hjson b/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats.hjson new file mode 100644 index 0000000000..ee687c99f9 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats.hjson @@ -0,0 +1,115 @@ +{ + tables: [{ + name: PlayerStats + namespace: PlayerNamespace + friendlyName: Player Statistics + table: player_stats + schema: gamedb + description: + // newlines are replaced by single space in handlebar if no helper function is applied + ''' + A long description + with newline + and additional space at start of this line. + ''' + category: Table Category + cardinality : lARge + hidden : false + readAccess : (user AND member) OR (admin AND NOT gu.est user) + filterTemplate : countryIsoCode=={{code}} + tags: ['GAME', 'PLAYER', ''' + A tag + with newline + '''] + hints: ['NoAggregateBeforeJoin'] + arguments: [ + { + name: scoreFormat + type: TEXT + default: 999999D00 + } + { + name: countryCode + type: TEXT + tableSource: { + table: Country + column: isoCode + } + default: US + } + ] + joins: [ + { + name: playerCountry + to: Country + type: Cross + }, + { + name: playerTeam + to: Team + kind: Tomany + type: left + definition: '{{playerTeam.id}} = {{ team_id}}' + } + ] + + measures : [ + { + name : highScore + friendlyName : High Score + type : INteGER + description : very awesome score + definition: 'MAX({{score}})' + tags: ['PUBLIC'] + }, + { + name : newHighScore + type : INteGER + description : very awesome score + definition: 'MAX({{score}})' + tags: ['PUBLIC'] + } + ] + dimensions : [ + { + name : countryIsoCode + friendlyName : Country ISO Code + type : TEXT + category : country detail + definition : '{{playerCountry.isoCode}}' + values : ['US', 'HK'] + tags: ['PRIVATE'] + cardinality: Small + }, + { + name : teamRegion + type : TEXT + definition : '{{playerTeam.region}}' + tableSource: { + table: PlayerStatsChild + namespace: PlayerNamespace + column: teamRegion + } + }, + { + name : createdOn + friendlyName : Created On + type : TIME + definition : '{{create_on}}' + filterTemplate : 'createdOn=={{createdOn}}' + grains: + [{ + type : DaY + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + }, + { + name : updatedOn + type : TIme + definition : '{{updated_on}}' + } + ] + }] +} diff --git a/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats_extends.hjson b/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats_extends.hjson new file mode 100644 index 0000000000..a7cae7e006 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats_extends.hjson @@ -0,0 +1,78 @@ +{ + tables: + [ + { + name: PlayerStatsChild + namespace: PlayerNamespace + extend: PlayerStats + description: PlayerStats Child + cardinality: large + measures : [ + { + name : highScore + type : Text + description : very awesome score + definition: 'MAX({{score}})' + tags: ['PUBLIC'] + }, + { + name : avgScore + type : INTEGER + description : Avg score + definition: 'Avg({{score}})' + tags: ['PUBLIC'] + } + ] + dimensions : [ + { + name : createdOn + type : TIME + definition : '{{create_on}}' + grains: + [{ + type : MONTH + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM') + ''' + }] + }, + { + name : createdYear + type : TIME + definition : '{{createdYear}}' + grains: + [{ + type : Year + sql : ''' + PARSEDATETIME(FORMATDATETIME(createdOn, 'yyyy-MM-dd'), 'yyyy') + ''' + }] + }, + { + name : createdWeekDate + type : TIME + definition : '{{createdOn}}' + grains: + [{ + type : isoWEEK + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + ''' + }] + }, + { + name : updatedMonth + type : TIME + definition : '{{updated_month}}' + grains: + [{ + type : mOnth + sql : ''' + PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyyMM') + ''' + }] + } + ] + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/valid/models/tables/referred_model.hjson b/elide-model-config/src/test/resources/validator/valid/models/tables/referred_model.hjson new file mode 100644 index 0000000000..3f050a0d8b --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/models/tables/referred_model.hjson @@ -0,0 +1,31 @@ +{ + tables: [ + { + name: Country + namespace: default + table: country + cardinality: small + dimensions: + [ + { + name: isoCode + type: TEXT + definition: '{{isoCode}}' + } + ] + } + { + name: Team + table: team + cardinality: small + dimensions: + [ + { + name: region + type: TEXT + definition: '{{region}}' + } + ] + } + ] +} diff --git a/elide-model-config/src/test/resources/validator/valid/models/variables.hjson b/elide-model-config/src/test/resources/validator/valid/models/variables.hjson new file mode 100644 index 0000000000..6d534ed7b7 --- /dev/null +++ b/elide-model-config/src/test/resources/validator/valid/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-spring/README.md b/elide-spring/README.md new file mode 100644 index 0000000000..0a6e6ddf85 --- /dev/null +++ b/elide-spring/README.md @@ -0,0 +1,6 @@ +# Elide Spring Artifacts + +Elide comes bundled with two Spring artifacts: + +1. [elide-spring-boot-autoconfigure](https://github.com/yahoo/elide/blob/master/elide-spring/elide-spring-boot-autoconfigure/README.md) - Provides controllers and configuration for using Elide with Spring Boot. +2. [elide-spring-boot-starter](https://github.com/yahoo/elide/blob/master/elide-spring/elide-spring-boot-starter/README.md) - Provides a Spring Boot dependency starter for an opinionated setup. diff --git a/elide-spring/elide-spring-boot-autoconfigure/README.md b/elide-spring/elide-spring-boot-autoconfigure/README.md new file mode 100644 index 0000000000..c6333a11e9 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/README.md @@ -0,0 +1,122 @@ +# Elide Spring Autoconfigure + +The Elide spring autoconfigure package provides the core code needed to use Elide with Spring Boot 2. It includes: +1. Rest API controllers for JSON-API, GraphQL, and Swagger documentation. +2. Configuration properties for common Elide settings. +3. Bean configurations for complete customization: + 1. `Elide` - provides complete control to configure Elide. + 2. `Entity Dictionary` - to register security checks and lifecycle hooks. + 3. `Data Store` - to override the default data store. + 4. `Swagger` - to control the Swagger document generation. + +## Configuration Properties + +| *Property* | *Required* | *Default* | *Description* | +| ------------------------------ | -----------| --------------- | -------------------------------------------------------- | +| elide.pageSize | No | 500 | Default pagination page size for collections | +| elide.maxPageSize | No | 10000 | Max pagination page size a client can request. | +| elide.json-api.path | No | '/' | URL path prefix for JSON-API endpoint. | +| elide.json-api.enabled | No | false | Whether or not the JSON-API endpoint is exposed. | +| elide.graphql.path | No | '/' | URL path prefix for GraphQL endpoint. | +| elide.graphql.enabled | No | false | Whether or not the GraphQL endpoint is exposed. | +| elide.swagger.path | No | '/' | URL path prefix for Swagger document endpoint. | +| elide.swagger.enabled | No | false | Whether or not the Swagger document endpoint is exposed. | +| elide.swagger.name | No | 'Elide Service' | Swagger documentation requires an API name. | +| elide.swagger.version | No | '1.0' | Swagger documentation requires an API version. | +| elide.queryCacheMaximumEntries | No | 1024 | Maximum number of entries in query result cache. | + + +## Entity Dictionary Override + +By default, auto configuration creates an `EntityDictionary` with no checks or life cycle hooks registered. It does register spring as the dependency injection framework for Elide model injection. + +```java + @Bean + public EntityDictionary buildDictionary(AutowireCapableBeanFactory beanFactory) { + return new EntityDictionary(new HashMap<>(), beanFactory::autowireBean); + } +``` + +A typical override would add some checks and life cycle hooks. *This is likely the only override you'll need*: + +```java + @Bean + public EntityDictionary buildDictionary(AutowireCapableBeanFactory beanFactory) { + HashMap> checkMappings = new HashMap<>(); + checkMappings.put("allow all", Role.ALL.class); + checkMappings.put("deny all", Role.NONE.class); + + EntityDictionary dictionary = new EntityDictionary(checkMappings, beanFactory::autowireBean); + dictionary.bindTrigger(Book.class, OnCreatePostCommit.class, (book, scope, changes) -> { /* DO SOMETHING */ }); + dictionary.bindTrigger(Book.class, OnUpdatePostCommit.class, "title", (book, scope, changes) -> { /* DO SOMETHING */ }); + } +``` + +## Data Store Override +By default, the auto configuration will wire up a JPA data store: + +```java + @Bean + public DataStore buildDataStore(EntityManagerFactory entityManagerFactory) { + return new JpaDataStore( + () -> { return entityManagerFactory.createEntityManager(); }, + (em -> { return new NonJtaTransaction(em); })); + } +``` + +Override this bean if you want a different store or multiple stores. + +## Swagger Override + +By default, Elide will generate swagger documentation for every model exposed into a single swagger document: + +```java + @Bean + public Swagger buildSwagger(EntityDictionary dictionary, ElideConfigProperties settings) { + Info info = new Info() + .title(settings.getSwagger().getName()) + .version(settings.getSwagger().getVersion()); + + SwaggerBuilder builder = new SwaggerBuilder(dictionary, info); + + Swagger swagger = builder.build().basePath(settings.getJsonApi().getPath()); + + return swagger; + } +``` + +You'll want to override this if: +1. You want to configure authentication for your swagger endpoint. +2. You don't want to expose all your models via swagger. +3. You want to break up your models into multiple swagger documents. + +The Swagger controller will also except a bean that returns: `Map + 4.0.0 + elide-spring-boot-autoconfigure + jar + Elide Spring Boot Autoconfigure + Elide Spring Boot Autoconfigure + https://github.com/yahoo/elide + + com.yahoo.elide + elide-spring-parent-pom + 6.1.4-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 + + + + utf-8 + + + + + + + + com.yahoo.elide + elide-core + 6.1.4-SNAPSHOT + true + + + + com.yahoo.elide + elide-graphql + 6.1.4-SNAPSHOT + true + + + + com.yahoo.elide + elide-datastore-aggregation + 6.1.4-SNAPSHOT + true + + + + com.yahoo.elide + elide-datastore-jpa + 6.1.4-SNAPSHOT + true + + + + com.yahoo.elide + elide-datastore-jms + 6.1.4-SNAPSHOT + true + + + + com.yahoo.elide + elide-swagger + 6.1.4-SNAPSHOT + true + + + + com.yahoo.elide + elide-async + 6.1.4-SNAPSHOT + true + + + + com.yahoo.elide + elide-model-config + 6.1.4-SNAPSHOT + true + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-websocket + true + + + + org.owasp.encoder + encoder + 1.2.3 + true + + + + org.apache.tomcat.embed + tomcat-embed-core + true + + + org.apache.tomcat.embed + tomcat-embed-websocket + true + + + + org.springframework.boot + spring-boot-starter-web + true + + + org.apache.tomcat.embed + tomcat-embed-core + + + org.apache.tomcat.embed + tomcat-embed-websocket + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + true + + + + org.springframework.boot + spring-boot-starter-security + ${spring.boot.version} + true + + + + org.springframework.cloud + spring-cloud-context + true + + + + io.micrometer + micrometer-core + 1.8.3 + true + + + + + + + + org.yaml + snakeyaml + test + + + + com.yahoo.elide + elide-test-helpers + 6.1.4-SNAPSHOT + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.vaadin.external.google + android-json + + + + + + org.springframework.boot + spring-boot-starter-artemis + test + + + + org.springframework.boot + spring-boot-starter-actuator + test + + + + org.apache.activemq + artemis-jms-server + 2.20.0 + test + + + + com.h2database + h2 + test + + + + io.rest-assured + rest-assured + test + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + org.junit.platform + junit-platform-commons + 1.8.2 + test + + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + + + org.apache.maven.plugins + maven-jar-plugin + + + + diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/AggregationStoreProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/AggregationStoreProperties.java new file mode 100644 index 0000000000..e80e09fcf7 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/AggregationStoreProperties.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.spring.config; + +import static com.yahoo.elide.datastores.aggregation.cache.CaffeineCache.DEFAULT_MAXIMUM_ENTRIES; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialect; +import lombok.Data; + +/** + * Extra properties for setting up aggregation data store. + */ +@Data +public class AggregationStoreProperties { + + /** + * Whether or not aggregation data store is enabled. + */ + private boolean enabled = false; + + /** + * Whether or not meta data store is enabled. + */ + private boolean enableMetaDataStore = false; + + /** + * {@link SQLDialect} type for default DataSource Object. + */ + private String defaultDialect = "Hive"; + + /** + * Limit on number of query cache entries. Non-positive values disable the query cache. + */ + private int queryCacheMaximumEntries = DEFAULT_MAXIMUM_ENTRIES; + + /** + * Default Cache Expiration. + */ + private long defaultCacheExpirationMinutes = 10; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/AsyncProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/AsyncProperties.java new file mode 100644 index 0000000000..e4fdc8c5fd --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/AsyncProperties.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import lombok.Data; + +/** + * Extra properties for setting up async query support. + */ +@Data +public class AsyncProperties { + + /** + * Default thread pool size. + */ + private int threadPoolSize = 5; + + /** + * Default max query run time. + */ + private int maxRunTimeSeconds = 3600; + + /** + * Default maximum permissible AsyncAfterSeconds value. + * The Async requests can be configured to execute synchronously before switching to asynchronous mode. + */ + private int maxAsyncAfterSeconds = 10; + + /** + * Whether or not the cleanup is enabled. + */ + private boolean cleanupEnabled = false; + + /** + * Default retention of async query and results. + */ + private int queryCleanupDays = 7; + + /** + * Polling interval to identify async queries that should be canceled. + */ + private int queryCancellationIntervalSeconds = 300; + + /** + * Whether or not to use the default implementation of AsyncAPIDAO. + * If false, the user will provide custom implementation of AsyncAPIDAO. + */ + private boolean defaultAsyncAPIDAO = true; + + /** + * Whether or not the async feature is enabled. + */ + private boolean enabled = false; + + /** + * Settings for the export controller. + */ + private ExportControllerProperties export; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ControllerProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ControllerProperties.java new file mode 100644 index 0000000000..58047f0a60 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ControllerProperties.java @@ -0,0 +1,25 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import lombok.Data; + +/** + * Settings for a Spring REST controller. + */ +@Data +public class ControllerProperties { + + /** + * Whether or not the controller is enabled. + */ + private boolean enabled = false; + + /** + * The URL path prefix for the controller. + */ + private String path = "/"; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/DynamicConfigProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/DynamicConfigProperties.java new file mode 100644 index 0000000000..6c569f4096 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/DynamicConfigProperties.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.spring.config; + +import lombok.Data; + +/** + * Extra properties for setting up dynamic model config. + */ +@Data +public class DynamicConfigProperties { + + /** + * Whether or not dynamic model config is enabled. + */ + private boolean enabled = false; + + /** + * The path where the config hjsons are stored. + */ + private String path = "/"; + + /** + * Enable support for reading and manipulating HJSON configuration through Elide models. + */ + private boolean configApiEnabled = false; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAsyncConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAsyncConfiguration.java new file mode 100644 index 0000000000..3235dad49a --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAsyncConfiguration.java @@ -0,0 +1,177 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.POSTCOMMIT; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PREFLUSH; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRESECURITY; +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.async.export.formatter.CSVExportFormatter; +import com.yahoo.elide.async.export.formatter.JSONExportFormatter; +import com.yahoo.elide.async.export.formatter.TableExportFormatter; +import com.yahoo.elide.async.hooks.AsyncQueryHook; +import com.yahoo.elide.async.hooks.TableExportHook; +import com.yahoo.elide.async.models.AsyncAPI; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.ResultType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.service.AsyncCleanerService; +import com.yahoo.elide.async.service.AsyncExecutorService; +import com.yahoo.elide.async.service.dao.AsyncAPIDAO; +import com.yahoo.elide.async.service.dao.DefaultAsyncAPIDAO; +import com.yahoo.elide.async.service.storageengine.FileResultStorageEngine; +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.core.security.RequestScope; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Async Configuration For Elide Services. Override any of the beans (by defining your own) + * and setting flags to disable in properties to change the default behavior. + */ +@Configuration +@EntityScan(basePackageClasses = AsyncQuery.class) +@EnableConfigurationProperties(ElideConfigProperties.class) +@ConditionalOnExpression("${elide.async.enabled:false}") +public class ElideAsyncConfiguration { + + /** + * Configure the AsyncExecutorService used for submitting async query requests. + * @param elide elideObject. + * @param settings Elide settings. + * @param asyncQueryDao AsyncDao object. + * @return a AsyncExecutorService. + */ + @Bean + @ConditionalOnMissingBean + public AsyncExecutorService buildAsyncExecutorService( + RefreshableElide elide, + ElideConfigProperties settings, + AsyncAPIDAO asyncQueryDao, + @Autowired(required = false) ResultStorageEngine resultStorageEngine + ) { + AsyncProperties asyncProperties = settings.getAsync(); + + ExecutorService executor = Executors.newFixedThreadPool(asyncProperties.getThreadPoolSize()); + ExecutorService updater = Executors.newFixedThreadPool(asyncProperties.getThreadPoolSize()); + AsyncExecutorService asyncExecutorService = new AsyncExecutorService(elide.getElide(), executor, + updater, asyncQueryDao); + + // Binding AsyncQuery LifeCycleHook + AsyncQueryHook asyncQueryHook = new AsyncQueryHook(asyncExecutorService, + asyncProperties.getMaxAsyncAfterSeconds()); + + EntityDictionary dictionary = elide.getElide().getElideSettings().getDictionary(); + + dictionary.bindTrigger(AsyncQuery.class, CREATE, PREFLUSH, asyncQueryHook, false); + dictionary.bindTrigger(AsyncQuery.class, CREATE, POSTCOMMIT, asyncQueryHook, false); + dictionary.bindTrigger(AsyncQuery.class, CREATE, PRESECURITY, asyncQueryHook, false); + + boolean exportEnabled = ElideAutoConfiguration.isExportEnabled(asyncProperties); + + if (exportEnabled) { + // Initialize the Formatters. + boolean skipCSVHeader = asyncProperties.getExport() != null + && asyncProperties.getExport().isSkipCSVHeader(); + Map supportedFormatters = new HashMap<>(); + supportedFormatters.put(ResultType.CSV, new CSVExportFormatter(elide.getElide(), skipCSVHeader)); + supportedFormatters.put(ResultType.JSON, new JSONExportFormatter(elide.getElide())); + + // Binding TableExport LifeCycleHook + TableExportHook tableExportHook = getTableExportHook(asyncExecutorService, settings, supportedFormatters, + resultStorageEngine); + dictionary.bindTrigger(TableExport.class, CREATE, PREFLUSH, tableExportHook, false); + dictionary.bindTrigger(TableExport.class, CREATE, POSTCOMMIT, tableExportHook, false); + dictionary.bindTrigger(TableExport.class, CREATE, PRESECURITY, tableExportHook, false); + } + + return asyncExecutorService; + } + + // TODO Remove this method when ElideSettings has all the settings. + // Then the check can be done in TableExportHook. + // Trying to avoid adding too many individual properties to ElideSettings for now. + // https://github.com/yahoo/elide/issues/1803 + private TableExportHook getTableExportHook(AsyncExecutorService asyncExecutorService, + ElideConfigProperties settings, Map supportedFormatters, + ResultStorageEngine resultStorageEngine) { + boolean exportEnabled = ElideAutoConfiguration.isExportEnabled(settings.getAsync()); + + TableExportHook tableExportHook = null; + if (exportEnabled) { + tableExportHook = new TableExportHook(asyncExecutorService, settings.getAsync().getMaxAsyncAfterSeconds(), + supportedFormatters, resultStorageEngine); + } else { + tableExportHook = new TableExportHook(asyncExecutorService, settings.getAsync().getMaxAsyncAfterSeconds(), + supportedFormatters, resultStorageEngine) { + @Override + public void validateOptions(AsyncAPI export, RequestScope requestScope) { + throw new InvalidOperationException("TableExport is not supported."); + } + }; + } + return tableExportHook; + } + + /** + * Configure the AsyncCleanerService used for cleaning up async query requests. + * @param elide elideObject. + * @param settings Elide settings. + * @param asyncQueryDao AsyncDao object. + * @return a AsyncCleanerService. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "elide.async", name = "cleanupEnabled", matchIfMissing = false) + public AsyncCleanerService buildAsyncCleanerService(RefreshableElide elide, + ElideConfigProperties settings, + AsyncAPIDAO asyncQueryDao) { + AsyncCleanerService.init(elide.getElide(), settings.getAsync().getMaxRunTimeSeconds(), + settings.getAsync().getQueryCleanupDays(), + settings.getAsync().getQueryCancellationIntervalSeconds(), asyncQueryDao); + return AsyncCleanerService.getInstance(); + } + + /** + * Configure the AsyncQueryDAO used by async query requests. + * @param elide elideObject. + * @return an AsyncQueryDAO object. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "elide.async", name = "defaultAsyncAPIDAO", matchIfMissing = true) + public AsyncAPIDAO buildAsyncAPIDAO(RefreshableElide elide) { + return new DefaultAsyncAPIDAO(elide.getElide().getElideSettings(), elide.getElide().getDataStore()); + } + + /** + * Configure the ResultStorageEngine used by async query requests. + * @param settings Elide settings. + * @return an ResultStorageEngine object. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "elide.async.export", name = "enabled", matchIfMissing = false) + public ResultStorageEngine buildResultStorageEngine(ElideConfigProperties settings) { + FileResultStorageEngine resultStorageEngine = new FileResultStorageEngine(settings.getAsync().getExport() + .getStorageDestination(), settings.getAsync().getExport().isExtensionEnabled()); + return resultStorageEngine; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java new file mode 100644 index 0000000000..9c826b439a --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java @@ -0,0 +1,510 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import static com.yahoo.elide.datastores.jpa.JpaDataStore.DEFAULT_LOGGER; +import static org.springframework.beans.factory.config.BeanDefinition.SCOPE_PROTOTYPE; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.async.models.AsyncQuery; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.core.TransactionRegistry; +import com.yahoo.elide.core.audit.Slf4jLogger; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.Injector; +import com.yahoo.elide.core.exceptions.ErrorMapper; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.security.checks.prefab.Role; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.datastores.aggregation.AggregationDataStore; +import com.yahoo.elide.datastores.aggregation.DefaultQueryValidator; +import com.yahoo.elide.datastores.aggregation.QueryEngine; +import com.yahoo.elide.datastores.aggregation.cache.Cache; +import com.yahoo.elide.datastores.aggregation.cache.CaffeineCache; +import com.yahoo.elide.datastores.aggregation.core.QueryLogger; +import com.yahoo.elide.datastores.aggregation.core.Slf4jQueryLogger; +import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.DataSourceConfiguration; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.SQLQueryEngine; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.query.AggregateBeforeJoinOptimizer; +import com.yahoo.elide.datastores.aggregation.validator.TemplateConfigValidator; +import com.yahoo.elide.datastores.jpa.JpaDataStore; +import com.yahoo.elide.datastores.jpa.transaction.NonJtaTransaction; +import com.yahoo.elide.datastores.multiplex.MultiplexManager; +import com.yahoo.elide.graphql.QueryRunners; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.jsonapi.links.DefaultJSONApiLinks; +import com.yahoo.elide.modelconfig.DBPasswordExtractor; +import com.yahoo.elide.modelconfig.DynamicConfiguration; +import com.yahoo.elide.modelconfig.store.ConfigDataStore; +import com.yahoo.elide.modelconfig.store.models.ConfigChecks; +import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; +import com.yahoo.elide.spring.controllers.SwaggerController; +import com.yahoo.elide.swagger.SwaggerBuilder; +import org.apache.commons.lang3.StringUtils; +import org.hibernate.Session; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; +import io.swagger.models.Info; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TimeZone; +import java.util.function.Consumer; +import java.util.function.Function; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.sql.DataSource; + +/** + * Auto Configuration For Elide Services. Override any of the beans (by defining your own) to change + * the default behavior. + */ +@Configuration +@EnableConfigurationProperties(ElideConfigProperties.class) +@Slf4j +public class ElideAutoConfiguration { + + @Autowired(required = false) + private MeterRegistry meterRegistry; + + private final Consumer txCancel = em -> em.unwrap(Session.class).cancelQuery(); + + /** + * Creates dynamic configuration for models, security roles, and database connections. + * @param settings Config Settings. + * @throws IOException if there is an error reading the configuration. + * @return An instance of DynamicConfiguration. + */ + @Bean + @Scope(SCOPE_PROTOTYPE) + @ConditionalOnMissingBean + @ConditionalOnExpression("${elide.aggregation-store.enabled:false} and ${elide.dynamic-config.enabled:false}") + public DynamicConfiguration buildDynamicConfiguration(ClassScanner scanner, + ElideConfigProperties settings) throws IOException { + DynamicConfigValidator validator = new DynamicConfigValidator(scanner, + settings.getDynamicConfig().getPath()); + validator.readAndValidateConfigs(); + return validator; + } + + @Bean + @ConditionalOnMissingBean + public TransactionRegistry createRegistry() { + return new TransactionRegistry(); + } + + /** + * Creates the default Password Extractor Implementation. + * @return An instance of DBPasswordExtractor. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "elide.aggregation-store.enabled", havingValue = "true") + public DBPasswordExtractor getDBPasswordExtractor() { + return config -> StringUtils.EMPTY; + } + + /** + * Provides the default Hikari DataSource Configuration. + * @return An instance of DataSourceConfiguration. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "elide.aggregation-store.enabled", havingValue = "true") + public DataSourceConfiguration getDataSourceConfiguration() { + return new DataSourceConfiguration() { + }; + } + + /** + * Creates the Elide instance with standard settings. + * @param dictionary Stores the static metadata about Elide models. + * @param dataStore The persistence store. + * @param transactionRegistry Global transaction registry. + * @param settings Elide settings. + * @return A new elide instance. + */ + @Bean + @RefreshScope + @ConditionalOnMissingBean + public RefreshableElide getRefreshableElide(EntityDictionary dictionary, + DataStore dataStore, + TransactionRegistry transactionRegistry, + ElideConfigProperties settings, + JsonApiMapper mapper, + ErrorMapper errorMapper) { + + ElideSettingsBuilder builder = new ElideSettingsBuilder(dataStore) + .withEntityDictionary(dictionary) + .withErrorMapper(errorMapper) + .withJsonApiMapper(mapper) + .withDefaultMaxPageSize(settings.getMaxPageSize()) + .withDefaultPageSize(settings.getPageSize()) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withAuditLogger(new Slf4jLogger()) + .withBaseUrl(settings.getBaseUrl()) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .withJsonApiPath(settings.getJsonApi().getPath()) + .withGraphQLApiPath(settings.getGraphql().getPath()); + + if (settings.isVerboseErrors()) { + builder.withVerboseErrors(); + } + + if (settings.getAsync() != null + && settings.getAsync().getExport() != null + && settings.getAsync().getExport().isEnabled()) { + builder.withExportApiPath(settings.getAsync().getExport().getPath()); + } + + if (settings.getJsonApi() != null + && settings.getJsonApi().isEnabled() + && settings.getJsonApi().isEnableLinks()) { + String baseUrl = settings.getBaseUrl(); + + if (StringUtils.isEmpty(baseUrl)) { + builder.withJSONApiLinks(new DefaultJSONApiLinks()); + } else { + String jsonApiBaseUrl = baseUrl + settings.getJsonApi().getPath() + "/"; + builder.withJSONApiLinks(new DefaultJSONApiLinks(jsonApiBaseUrl)); + } + } + + Elide elide = new Elide(builder.build(), transactionRegistry, dictionary.getScanner(), true); + + return new RefreshableElide(elide); + } + + @Bean + @RefreshScope + @ConditionalOnMissingBean + public QueryRunners getQueryRunners(RefreshableElide refreshableElide) { + return new QueryRunners(refreshableElide); + } + + /** + * A Set containing Types to be excluded from EntityDictionary's EntityBinding. + * @param settings Elide configuration settings. + * @return Set of Types. + */ + @Bean(name = "entitiesToExclude") + @ConditionalOnMissingBean + public Set> getEntitiesToExclude(ElideConfigProperties settings) { + Set> entitiesToExclude = new HashSet<>(); + + AsyncProperties asyncProperties = settings.getAsync(); + + if (asyncProperties == null || !asyncProperties.isEnabled()) { + entitiesToExclude.add(ClassType.of(AsyncQuery.class)); + } + + boolean exportEnabled = isExportEnabled(asyncProperties); + + if (!exportEnabled) { + entitiesToExclude.add(ClassType.of(TableExport.class)); + } + + return entitiesToExclude; + } + + /** + * Creates the entity dictionary for Elide which contains static metadata about Elide models. + * Override to load check classes or life cycle hooks. + * @param beanFactory Injector to inject Elide models. + * @param dynamicConfig An instance of DynamicConfiguration. + * @param settings Elide configuration settings. + * @param entitiesToExclude set of Entities to exclude from binding. + * @return a newly configured EntityDictionary. + */ + @Bean + @ConditionalOnMissingBean + @Scope(SCOPE_PROTOTYPE) + public EntityDictionary buildDictionary(AutowireCapableBeanFactory beanFactory, + ClassScanner scanner, + @Autowired(required = false) DynamicConfiguration dynamicConfig, + ElideConfigProperties settings, + @Qualifier("entitiesToExclude") Set> entitiesToExclude) { + + Map> checks = new HashMap<>(); + + if (settings.getDynamicConfig().isConfigApiEnabled()) { + checks.put(ConfigChecks.CAN_CREATE_CONFIG, ConfigChecks.CanNotCreate.class); + checks.put(ConfigChecks.CAN_READ_CONFIG, ConfigChecks.CanNotRead.class); + checks.put(ConfigChecks.CAN_DELETE_CONFIG, ConfigChecks.CanNotDelete.class); + checks.put(ConfigChecks.CAN_UPDATE_CONFIG, ConfigChecks.CanNotUpdate.class); + } + + EntityDictionary dictionary = new EntityDictionary( + checks, //Checks + new HashMap<>(), //Role Checks + new Injector() { + @Override + public void inject(Object entity) { + beanFactory.autowireBean(entity); + } + + @Override + public T instantiate(Class cls) { + return beanFactory.createBean(cls); + } + }, + CoerceUtil::lookup, //Serde Lookup + entitiesToExclude, + scanner); + + if (isAggregationStoreEnabled(settings) && isDynamicConfigEnabled(settings)) { + dynamicConfig.getRoles().forEach(role -> { + dictionary.addRoleCheck(role, new Role.RoleMemberCheck(role)); + }); + } + + return dictionary; + } + + /** + * Create a QueryEngine instance for aggregation data store to use. + * @param defaultDataSource DataSource for JPA. + * @param dynamicConfig An instance of DynamicConfiguration. + * @param settings Elide configuration settings. + * @param dataSourceConfiguration DataSource Configuration + * @param dbPasswordExtractor Password Extractor Implementation + * @return An instance of a QueryEngine + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "elide.aggregation-store.enabled", havingValue = "true") + @Scope(SCOPE_PROTOTYPE) + public QueryEngine buildQueryEngine(DataSource defaultDataSource, + @Autowired(required = false) DynamicConfiguration dynamicConfig, + ElideConfigProperties settings, + ClassScanner scanner, + DataSourceConfiguration dataSourceConfiguration, + DBPasswordExtractor dbPasswordExtractor) { + + boolean enableMetaDataStore = settings.getAggregationStore().isEnableMetaDataStore(); + ConnectionDetails defaultConnectionDetails = new ConnectionDetails(defaultDataSource, + SQLDialectFactory.getDialect(settings.getAggregationStore().getDefaultDialect())); + if (isDynamicConfigEnabled(settings)) { + MetaDataStore metaDataStore = new MetaDataStore(scanner, dynamicConfig.getTables(), + dynamicConfig.getNamespaceConfigurations(), enableMetaDataStore); + + Map connectionDetailsMap = new HashMap<>(); + + dynamicConfig.getDatabaseConfigurations().forEach(dbConfig -> { + connectionDetailsMap.put(dbConfig.getName(), + new ConnectionDetails( + dataSourceConfiguration.getDataSource(dbConfig, dbPasswordExtractor), + SQLDialectFactory.getDialect(dbConfig.getDialect()))); + }); + + Function connectionDetailsLookup = (name) -> { + if (StringUtils.isEmpty(name)) { + return defaultConnectionDetails; + } + return Optional.ofNullable(connectionDetailsMap.get(name)) + .orElseThrow(() -> new IllegalStateException("ConnectionDetails undefined for connection: " + + name)); + }; + + return new SQLQueryEngine(metaDataStore, connectionDetailsLookup, + new HashSet<>(Arrays.asList(new AggregateBeforeJoinOptimizer(metaDataStore))), + new DefaultQueryPlanMerger(metaDataStore), + new DefaultQueryValidator(metaDataStore.getMetadataDictionary())); + } + MetaDataStore metaDataStore = new MetaDataStore(scanner, enableMetaDataStore); + return new SQLQueryEngine(metaDataStore, (unused) -> defaultConnectionDetails); + } + + /** + * Creates the DataStore Elide. Override to use a different store. + * @param entityManagerFactory The JPA factory which creates entity managers. + * @param scanner Class Scanner + * @param queryEngine QueryEngine instance for aggregation data store. + * @param settings Elide configuration settings. + * @param cache Analytics query cache + * @param querylogger Analytics query logger + * @return An instance of a JPA DataStore. + */ + @Bean + @ConditionalOnMissingBean + @Scope(SCOPE_PROTOTYPE) + public DataStore buildDataStore(EntityManagerFactory entityManagerFactory, + ClassScanner scanner, + @Autowired(required = false) QueryEngine queryEngine, + ElideConfigProperties settings, + @Autowired(required = false) Cache cache, + @Autowired(required = false) QueryLogger querylogger) { + List stores = new ArrayList<>(); + + JpaDataStore jpaDataStore = new JpaDataStore( + entityManagerFactory::createEntityManager, + em -> new NonJtaTransaction(em, txCancel, + DEFAULT_LOGGER, + settings.getJpaStore().isDelegateToInMemoryStore(), true)); + + stores.add(jpaDataStore); + + if (isAggregationStoreEnabled(settings)) { + AggregationDataStore.AggregationDataStoreBuilder aggregationDataStoreBuilder = + AggregationDataStore.builder().queryEngine(queryEngine); + + if (isDynamicConfigEnabled(settings)) { + aggregationDataStoreBuilder.dynamicCompiledClasses(queryEngine.getMetaDataStore().getDynamicTypes()); + + if (settings.getDynamicConfig().isConfigApiEnabled()) { + stores.add(new ConfigDataStore(settings.getDynamicConfig().getPath(), + new TemplateConfigValidator(scanner, settings.getDynamicConfig().getPath()))); + } + } + aggregationDataStoreBuilder.cache(cache); + aggregationDataStoreBuilder.queryLogger(querylogger); + AggregationDataStore aggregationDataStore = aggregationDataStoreBuilder.build(); + + stores.add(queryEngine.getMetaDataStore()); + stores.add(aggregationDataStore); + + // meta data store needs to be put at first to populate meta data models + return new MultiplexManager(stores.toArray(new DataStore[0])); + } + + return jpaDataStore; + } + + /** + * Creates a query result cache to be used by {@link #buildDataStore}, or null if cache is to be disabled. + * @param settings Elide configuration settings. + * @return An instance of a query cache, or null. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "elide.aggregation-store.enabled", havingValue = "true") + public Cache buildQueryCache(ElideConfigProperties settings) { + CaffeineCache cache = null; + + int maxCacheItems = settings.getAggregationStore().getQueryCacheMaximumEntries(); + if (maxCacheItems > 0) { + cache = new CaffeineCache(maxCacheItems, settings.getAggregationStore().getDefaultCacheExpirationMinutes()); + if (meterRegistry != null) { + CaffeineCacheMetrics.monitor(meterRegistry, cache.getImplementation(), "elideQueryCache"); + } + } + return cache; + } + + /** + * Creates a querylogger to be used by {@link #buildDataStore} for aggregation. + * @return The default Noop QueryLogger. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "elide.aggregation-store.enabled", havingValue = "true") + public QueryLogger buildQueryLogger() { + return new Slf4jQueryLogger(); + } + + /** + * Creates a singular swagger document for JSON-API. + * @param elide Singleton elide instance. + * @param settings Elide configuration settings. + * @return An instance of a JPA DataStore. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "elide.swagger.enabled", havingValue = "true") + @RefreshScope + public SwaggerController.SwaggerRegistrations buildSwagger( + RefreshableElide elide, + ElideConfigProperties settings + ) { + EntityDictionary dictionary = elide.getElide().getElideSettings().getDictionary(); + Info info = new Info() + .title(settings.getSwagger().getName()) + .version(settings.getSwagger().getVersion()); + + SwaggerBuilder builder = new SwaggerBuilder(dictionary, info).withLegacyFilterDialect(false); + return new SwaggerController.SwaggerRegistrations( + builder.build().basePath(settings.getJsonApi().getPath()) + ); + } + + @Bean + @ConditionalOnMissingBean + public ClassScanner getClassScanner() { + return new DefaultClassScanner(); + } + + @Bean + @ConditionalOnMissingBean + public ErrorMapper getErrorMapper() { + return error -> null; + } + + @Bean + @ConditionalOnMissingBean + @Scope(SCOPE_PROTOTYPE) + public JsonApiMapper mapper() { + return new JsonApiMapper(); + } + + private boolean isDynamicConfigEnabled(ElideConfigProperties settings) { + + boolean enabled = false; + if (settings.getDynamicConfig() != null) { + enabled = settings.getDynamicConfig().isEnabled(); + } + + return enabled; + + } + + private boolean isAggregationStoreEnabled(ElideConfigProperties settings) { + + boolean enabled = false; + if (settings.getAggregationStore() != null) { + enabled = settings.getAggregationStore().isEnabled(); + } + + return enabled; + + } + + public static boolean isExportEnabled(AsyncProperties asyncProperties) { + + return asyncProperties != null && asyncProperties.getExport() != null + && asyncProperties.getExport().isEnabled(); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideConfigProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideConfigProperties.java new file mode 100644 index 0000000000..35cd01745b --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideConfigProperties.java @@ -0,0 +1,78 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import lombok.Data; + +/** + * Configuration settings for Elide. + */ +@Data +@ConfigurationProperties(prefix = "elide") +public class ElideConfigProperties { + + /** + * Settings for the JSON-API controller. + */ + private JsonApiControllerProperties jsonApi; + + /** + * Settings for the GraphQL controller. + */ + private ControllerProperties graphql; + + /** + * Settings for the Swagger document controller. + */ + private SwaggerControllerProperties swagger; + + /** + * Settings for the Async. + */ + private AsyncProperties async = new AsyncProperties(); + + /** + * Settings for subscriptions. + */ + private SubscriptionProperties subscription = new SubscriptionProperties(); + + /** + * Settings for the Dynamic Configuration. + */ + private DynamicConfigProperties dynamicConfig = new DynamicConfigProperties(); + + /** + * Settings for the Aggregation Store. + */ + private AggregationStoreProperties aggregationStore = new AggregationStoreProperties(); + + /** + * Settings for the JPA Store. + */ + private JpaStoreProperties jpaStore = new JpaStoreProperties(); + + /** + * Default pagination size for collections if the client doesn't paginate. + */ + private int pageSize = 500; + + /** + * The maximum pagination size a client can request. + */ + private int maxPageSize = 10000; + + /** + * The base service URL that clients use in queries. Elide will reference this name + * in any callback URLs returned by the service. If not set, Elide uses the API request to derive the base URL. + */ + private String baseUrl = ""; + + /** + * Turns on/off verbose error responses. + */ + private boolean verboseErrors = false; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionConfiguration.java new file mode 100644 index 0000000000..0179db08bf --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionConfiguration.java @@ -0,0 +1,72 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import static com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket.DEFAULT_USER_FACTORY; +import com.yahoo.elide.core.audit.Slf4jLogger; +import com.yahoo.elide.core.exceptions.ErrorMapper; +import com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketConfigurator; +import com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.server.standard.ServerEndpointExporter; + +import javax.jms.ConnectionFactory; +import javax.websocket.server.ServerEndpointConfig; + +/** + * Configures GraphQL subscription web sockets for Elide. + */ +@Configuration +@EnableConfigurationProperties(ElideConfigProperties.class) +public class ElideSubscriptionConfiguration { + @Bean + @ConditionalOnMissingBean + @ConditionalOnExpression("${elide.subscription.enabled:false}") + ServerEndpointConfig serverEndpointConfig( + ElideConfigProperties config, + SubscriptionWebSocket.UserFactory userFactory, + ConnectionFactory connectionFactory, + ErrorMapper errorMapper, + JsonApiMapper mapper + ) { + return ServerEndpointConfig.Builder + .create(SubscriptionWebSocket.class, config.getSubscription().getPath()) + .configurator(SubscriptionWebSocketConfigurator.builder() + .baseUrl(config.getSubscription().getPath()) + .sendPingOnSubscribe(config.getSubscription().isSendPingOnSubscribe()) + .connectionTimeoutMs(config.getSubscription().getConnectionTimeoutMs()) + .maxSubscriptions(config.getSubscription().maxSubscriptions) + .maxMessageSize(config.getSubscription().maxMessageSize) + .maxIdleTimeoutMs(config.getSubscription().idleTimeoutMs) + .connectionFactory(connectionFactory) + .userFactory(userFactory) + .auditLogger(new Slf4jLogger()) + .verboseErrors(config.isVerboseErrors()) + .errorMapper(errorMapper) + .build()) + .build(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnExpression("${elide.subscription.enabled:false}") + ServerEndpointExporter serverEndpointExporter() { + ServerEndpointExporter exporter = new ServerEndpointExporter(); + return exporter; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnExpression("${elide.subscription.enabled:false}") + SubscriptionWebSocket.UserFactory getUserFactory() { + return DEFAULT_USER_FACTORY; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionScanningConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionScanningConfiguration.java new file mode 100644 index 0000000000..4e1c88dad2 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionScanningConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.graphql.subscriptions.hooks.SubscriptionScanner; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; + +import javax.jms.ConnectionFactory; +import javax.jms.Message; + +/** + * Scans for GraphQL subscriptions and registers lifecycle hooks. + */ +@Configuration +@ConditionalOnExpression("${elide.subscription.enabled:false}") +public class ElideSubscriptionScanningConfiguration { + private RefreshableElide refreshableElide; + private ConnectionFactory connectionFactory; + + @Autowired + public ElideSubscriptionScanningConfiguration( + RefreshableElide refreshableElide, + ConnectionFactory connectionFactory + ) { + this.refreshableElide = refreshableElide; + this.connectionFactory = connectionFactory; + } + + @EventListener(value = { ContextRefreshedEvent.class, RefreshScopeRefreshedEvent.class }) + public void onStartOrRefresh(ApplicationEvent event) { + + Elide elide = refreshableElide.getElide(); + + SubscriptionScanner scanner = SubscriptionScanner.builder() + + //Things you may want to override... + .deliveryDelay(Message.DEFAULT_DELIVERY_DELAY) + .messagePriority(Message.DEFAULT_PRIORITY) + .timeToLive(Message.DEFAULT_TIME_TO_LIVE) + .deliveryMode(Message.DEFAULT_DELIVERY_MODE) + + //Things you probably don't care about... + .scanner(elide.getScanner()) + .dictionary(elide.getElideSettings().getDictionary()) + .connectionFactory(connectionFactory) + .build(); + + scanner.bindLifecycleHooks(); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ExportControllerProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ExportControllerProperties.java new file mode 100644 index 0000000000..96a61db2dd --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ExportControllerProperties.java @@ -0,0 +1,37 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Extra controller properties for the export endpoint. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class ExportControllerProperties extends ControllerProperties { + + /** + * Skip including Header in CSV formatted export. + */ + private boolean skipCSVHeader = false; + + /** + * Enable Adding Extension to table export attachments. + */ + private boolean extensionEnabled = false; + + /** + * The URL path prefix for the controller. + */ + private String path = "/export"; + + /** + * Storage engine destination . + */ + private String storageDestination = "/tmp"; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/JpaStoreProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/JpaStoreProperties.java new file mode 100644 index 0000000000..983b9aee9d --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/JpaStoreProperties.java @@ -0,0 +1,21 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import lombok.Data; + +/** + * Extra properties for setting up aggregation data store. + */ +@Data +public class JpaStoreProperties { + + /** + * When fetching a subcollection from another multi-element collection, whether or not to do sorting, filtering + * and pagination in memory - or do N+1 queries. + */ + private boolean delegateToInMemoryStore = true; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/JsonApiControllerProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/JsonApiControllerProperties.java new file mode 100644 index 0000000000..64369ba0ae --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/JsonApiControllerProperties.java @@ -0,0 +1,22 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Extra controller properties for the JSON-API endpoint. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class JsonApiControllerProperties extends ControllerProperties { + + /** + * Turns on/off JSON-API links in the API. + */ + boolean enableLinks = false; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/SubscriptionProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/SubscriptionProperties.java new file mode 100644 index 0000000000..3c0d410e30 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/SubscriptionProperties.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import lombok.Data; + +/** + * Extra properties for setting up GraphQL subscription support. + */ +@Data +public class SubscriptionProperties extends ControllerProperties { + + /** + * Whether Elide should publish subscription notifications to JMS on lifecycle events. + */ + protected boolean publishingEnabled = isEnabled(); + + /** + * Websocket sends a PING immediate after receiving a SUBSCRIBE. Only useful for testing. + * @see com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketTestClient + */ + protected boolean sendPingOnSubscribe = false; + + /** + * Time allowed in milliseconds from web socket creation to successfully receiving a CONNECTION_INIT message. + */ + protected int connectionTimeoutMs = 5000; + + /** + * Maximum number of outstanding GraphQL queries per websocket. + */ + protected int maxSubscriptions = 30; + + /** + * Maximum message size that can be sent to the websocket. + */ + protected int maxMessageSize = 10000; + + /** + * Maximum idle timeout in milliseconds with no websocket activity. + */ + protected long idleTimeoutMs = 300000; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/SwaggerControllerProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/SwaggerControllerProperties.java new file mode 100644 index 0000000000..40c272a922 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/SwaggerControllerProperties.java @@ -0,0 +1,27 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Extra controller properties for the Swagger document endpoint. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class SwaggerControllerProperties extends ControllerProperties { + + /** + * Swagger needs a name for the service. + */ + private String name = "Elide Service"; + + /** + * Swagger needs a version for the service. + */ + private String version = ""; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/ExportController.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/ExportController.java new file mode 100644 index 0000000000..616266aad3 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/ExportController.java @@ -0,0 +1,104 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.controllers; + +import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.exceptions.HttpStatus; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import io.reactivex.Observable; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import javax.servlet.http.HttpServletResponse; + +/** + * Spring rest controller for Elide Export. + * When enabled it is highly recommended to + * configure explicitly the TaskExecutor used in Spring MVC for executing + * asynchronous requests using StreamingResponseBody. Refer + * {@link org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody}. + */ +@Slf4j +@Configuration +@RestController +@RequestMapping(value = "${elide.async.export.path:/export}") +@ConditionalOnExpression("${elide.async.enabled:false} && ${elide.async.export.enabled:false}") +public class ExportController { + + private ResultStorageEngine resultStorageEngine; + + @Autowired + public ExportController(ResultStorageEngine resultStorageEngine) { + log.debug("Started ~~"); + this.resultStorageEngine = resultStorageEngine; + } + + /** + * Single entry point for export requests. + * @param asyncQueryId Id of results to download + * @param response HttpServletResponse instance + * @return ResponseEntity + */ + @GetMapping(path = "/{asyncQueryId}") + public ResponseEntity export(@PathVariable String asyncQueryId, + HttpServletResponse response) { + + Observable observableResults = resultStorageEngine.getResultsByID(asyncQueryId); + StreamingResponseBody streamingOutput = outputStream -> { + observableResults + .subscribe( + resultString -> { + outputStream.write(resultString.concat(System.lineSeparator()).getBytes()); + }, + error -> { + String message = error.getMessage(); + try { + log.debug(message); + if (message != null && message.equals(ResultStorageEngine.RETRIEVE_ERROR)) { + response.sendError(HttpStatus.SC_NOT_FOUND, asyncQueryId + "not found"); + } else { + response.sendError(HttpStatus.SC_INTERNAL_SERVER_ERROR); + } + } catch (IOException | IllegalStateException e) { + // If stream was flushed, Attachment download has already started. + // response.sendError causes java.lang.IllegalStateException: + // Cannot call sendError() after the response has been committed. + // This will return 200 status. + // Add error message in the attachment as a way to signal errors. + outputStream.write( + "Error Occured...." + .concat(System.lineSeparator()) + .getBytes() + ); + log.debug(e.getMessage()); + } finally { + outputStream.flush(); + outputStream.close(); + } + }, + () -> { + outputStream.flush(); + outputStream.close(); + } + ); + }; + + return ResponseEntity + .ok() + .header("Content-Disposition", "attachment; filename=" + asyncQueryId) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(streamingOutput); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/GraphqlController.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/GraphqlController.java new file mode 100644 index 0000000000..c2966c922e --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/GraphqlController.java @@ -0,0 +1,114 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.controllers; + +import static com.yahoo.elide.graphql.QueryRunner.buildErrorResponse; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.graphql.QueryRunner; +import com.yahoo.elide.graphql.QueryRunners; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.spring.config.ElideConfigProperties; +import com.yahoo.elide.spring.security.AuthenticationUser; +import com.yahoo.elide.utils.HeaderUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Callable; + +/** + * Spring rest controller for Elide GraphQL. + */ +@Slf4j +@Configuration +@RestController +@RequestMapping(value = "${elide.graphql.path}") +@EnableConfigurationProperties(ElideConfigProperties.class) +@ConditionalOnExpression("${elide.graphql.enabled:false}") +@RefreshScope +public class GraphqlController { + + private final ElideConfigProperties settings; + private final QueryRunners runners; + private final ObjectMapper mapper; + + private static final String JSON_CONTENT_TYPE = "application/json"; + + @Autowired + public GraphqlController( + QueryRunners runners, + JsonApiMapper jsonApiMapper, + ElideConfigProperties settings) { + log.debug("Started ~~"); + this.runners = runners; + this.settings = settings; + this.mapper = jsonApiMapper.getObjectMapper(); + } + + /** + * Single entry point for GraphQL requests. + * + * @param requestHeaders request headers + * @param graphQLDocument post data as json document + * @param principal The user principal + * @return response + */ + @PostMapping(value = {"/**", ""}, consumes = JSON_CONTENT_TYPE, produces = JSON_CONTENT_TYPE) + public Callable> post(@RequestHeader HttpHeaders requestHeaders, + @RequestBody String graphQLDocument, Authentication principal) { + final User user = new AuthenticationUser(principal); + final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); + final Map> requestHeadersCleaned = + HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final QueryRunner runner = runners.getRunner(apiVersion); + final String baseUrl = getBaseUrlEndpoint(); + + return new Callable>() { + @Override + public ResponseEntity call() throws Exception { + ElideResponse response; + + if (runner == null) { + response = buildErrorResponse(mapper, new InvalidOperationException("Invalid API Version"), false); + } else { + Elide elide = runner.getElide(); + response = runner.run(baseUrl, graphQLDocument, user, UUID.randomUUID(), requestHeadersCleaned); + } + + return ResponseEntity.status(response.getResponseCode()).body(response.getBody()); + } + }; + } + + protected String getBaseUrlEndpoint() { + String baseUrl = settings.getBaseUrl(); + + if (StringUtils.isEmpty(baseUrl)) { + baseUrl = ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString(); + } + return baseUrl; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java new file mode 100644 index 0000000000..e27ad5da1c --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java @@ -0,0 +1,208 @@ +/* + * Copyright 2019, the original author or authors. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.controllers; + +import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.spring.config.ElideConfigProperties; +import com.yahoo.elide.spring.security.AuthenticationUser; +import com.yahoo.elide.utils.HeaderUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Callable; +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.MultivaluedHashMap; + +/** + * Spring rest controller for Elide JSON-API. + * Based on 'https://github.com/illyasviel/elide-spring-boot/' + */ +@Slf4j +@RestController +@Configuration +@RequestMapping(value = "${elide.json-api.path}") +@ConditionalOnExpression("${elide.json-api.enabled:false}") +@RefreshScope +public class JsonApiController { + + private final Elide elide; + private final ElideConfigProperties settings; + public static final String JSON_API_CONTENT_TYPE = JSONAPI_CONTENT_TYPE; + public static final String JSON_API_PATCH_CONTENT_TYPE = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; + + @Autowired + public JsonApiController(RefreshableElide refreshableElide, ElideConfigProperties settings) { + log.debug("Started ~~"); + this.settings = settings; + this.elide = refreshableElide.getElide(); + } + + private MultivaluedHashMap convert(MultiValueMap springMVMap) { + MultivaluedHashMap convertedMap = new MultivaluedHashMap<>(springMVMap.size()); + springMVMap.forEach(convertedMap::put); + return convertedMap; + } + + @GetMapping(value = "/**", produces = JSON_API_CONTENT_TYPE) + public Callable> elideGet(@RequestHeader HttpHeaders requestHeaders, + @RequestParam MultiValueMap allRequestParams, + HttpServletRequest request, Authentication authentication) { + final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); + final Map> requestHeadersCleaned = + HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); + final User user = new AuthenticationUser(authentication); + final String baseUrl = getBaseUrlEndpoint(); + + return new Callable>() { + @Override + public ResponseEntity call() throws Exception { + ElideResponse response = elide.get(baseUrl, pathname, + convert(allRequestParams), requestHeadersCleaned, + user, apiVersion, UUID.randomUUID()); + return ResponseEntity.status(response.getResponseCode()).body(response.getBody()); + } + }; + } + + @PostMapping(value = "/**", consumes = JSON_API_CONTENT_TYPE, produces = JSON_API_CONTENT_TYPE) + public Callable> elidePost(@RequestHeader HttpHeaders requestHeaders, + @RequestParam MultiValueMap allRequestParams, + @RequestBody String body, + HttpServletRequest request, Authentication authentication) { + final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); + final Map> requestHeadersCleaned = + HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); + final User user = new AuthenticationUser(authentication); + final String baseUrl = getBaseUrlEndpoint(); + + return new Callable>() { + @Override + public ResponseEntity call() throws Exception { + ElideResponse response = elide.post(baseUrl, pathname, body, convert(allRequestParams), + requestHeadersCleaned, user, apiVersion, UUID.randomUUID()); + return ResponseEntity.status(response.getResponseCode()).body(response.getBody()); + } + }; + } + + @PatchMapping(value = "/**", consumes = { JSON_API_CONTENT_TYPE, JSON_API_PATCH_CONTENT_TYPE}) + public Callable> elidePatch(@RequestHeader HttpHeaders requestHeaders, + @RequestParam MultiValueMap allRequestParams, + @RequestBody String body, + HttpServletRequest request, Authentication authentication) { + final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); + final Map> requestHeadersCleaned = + HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); + final User user = new AuthenticationUser(authentication); + final String baseUrl = getBaseUrlEndpoint(); + + return new Callable>() { + @Override + public ResponseEntity call() throws Exception { + ElideResponse response = elide + .patch(baseUrl, request.getContentType(), request.getContentType(), pathname, body, + convert(allRequestParams), requestHeadersCleaned, user, apiVersion, + UUID.randomUUID()); + return ResponseEntity.status(response.getResponseCode()).body(response.getBody()); + } + }; + } + + @DeleteMapping(value = "/**") + public Callable> elideDelete(@RequestHeader HttpHeaders requestHeaders, + @RequestParam MultiValueMap allRequestParams, + HttpServletRequest request, + Authentication authentication) { + final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); + final Map> requestHeadersCleaned = + HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); + final User user = new AuthenticationUser(authentication); + final String baseUrl = getBaseUrlEndpoint(); + + return new Callable>() { + @Override + public ResponseEntity call() throws Exception { + ElideResponse response = elide.delete(baseUrl, pathname, null, + convert(allRequestParams), requestHeadersCleaned, + user, apiVersion, UUID.randomUUID()); + return ResponseEntity.status(response.getResponseCode()).body(response.getBody()); + } + }; + } + + @DeleteMapping(value = "/**", consumes = JSON_API_CONTENT_TYPE) + public Callable> elideDeleteRelation( + @RequestHeader HttpHeaders requestHeaders, + @RequestParam MultiValueMap allRequestParams, + @RequestBody String body, + HttpServletRequest request, + Authentication authentication) { + final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); + final Map> requestHeadersCleaned = + HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); + final User user = new AuthenticationUser(authentication); + final String baseUrl = getBaseUrlEndpoint(); + + return new Callable>() { + @Override + public ResponseEntity call() throws Exception { + ElideResponse response = elide + .delete(baseUrl, pathname, body, convert(allRequestParams), + requestHeadersCleaned, user, apiVersion, UUID.randomUUID()); + return ResponseEntity.status(response.getResponseCode()).body(response.getBody()); + } + }; + } + + private String getJsonApiPath(HttpServletRequest request, String prefix) { + String pathname = (String) request + .getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); + + return pathname.replaceFirst(prefix, ""); + } + + protected String getBaseUrlEndpoint() { + String baseUrl = elide.getElideSettings().getBaseUrl(); + + if (StringUtils.isEmpty(baseUrl)) { + baseUrl = ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString(); + } + + return baseUrl; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/SwaggerController.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/SwaggerController.java new file mode 100644 index 0000000000..0c4b897e18 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/SwaggerController.java @@ -0,0 +1,144 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.controllers; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import com.yahoo.elide.swagger.SwaggerBuilder; +import com.yahoo.elide.utils.HeaderUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.owasp.encoder.Encode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import io.swagger.models.Swagger; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + + +/** + * Spring REST controller for exposing Swagger documentation. + */ +@Slf4j +@RefreshScope +@RestController +@Configuration +@RequestMapping(value = "${elide.swagger.path}") +@ConditionalOnExpression("${elide.swagger.enabled:false}") +public class SwaggerController { + + //Maps api version & path to swagger document. + protected Map, String> documents; + private static final String JSON_CONTENT_TYPE = "application/json"; + + /** + * Wraps a list of swagger registrations so that they can be wrapped with an AOP proxy. + */ + @Data + @AllArgsConstructor + public static class SwaggerRegistrations { + + public SwaggerRegistrations(Swagger doc) { + registrations = List.of(new SwaggerRegistration("", doc)); + } + + List registrations; + } + + @Data + @AllArgsConstructor + public static class SwaggerRegistration { + private String path; + private Swagger document; + } + + /** + * Constructs the resource. + * + * @param docs A list of documents to register. + */ + @Autowired + public SwaggerController(SwaggerRegistrations docs) { + log.debug("Started ~~"); + documents = new HashMap<>(); + + docs.getRegistrations().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)); + }); + } + + @GetMapping(value = {"/", ""}, produces = JSON_CONTENT_TYPE) + public Callable> list(@RequestHeader HttpHeaders requestHeaders) { + final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); + + final List documentPaths = documents.keySet().stream() + .filter(key -> key.getLeft().equals(apiVersion)) + .map(Pair::getRight) + .collect(Collectors.toList()); + + return new Callable>() { + @Override + public ResponseEntity call() throws Exception { + if (documentPaths.size() == 1) { + return ResponseEntity + .status(HttpStatus.OK) + .body(documents.values().iterator().next()); + } + + String body = documentPaths.stream() + .map(key -> '"' + key + '"') + .collect(Collectors.joining(",", "[", "]")); + + return ResponseEntity.status(HttpStatus.OK).body(body); + } + }; + } + + /** + * Read handler. + * + * @param requestHeaders request headers + * @param name document name + * @return response The Swagger JSON document + */ + @GetMapping(value = "/{name}", produces = JSON_CONTENT_TYPE) + public Callable> list(@RequestHeader HttpHeaders requestHeaders, + @PathVariable("name") String name) { + + final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); + final String encodedName = Encode.forHtml(name); + + return new Callable>() { + @Override + public ResponseEntity call() throws Exception { + Pair lookupKey = Pair.of(apiVersion, encodedName); + if (documents.containsKey(lookupKey)) { + return ResponseEntity.status(HttpStatus.OK).body(documents.get(lookupKey)); + } + return ResponseEntity.status(404).body("Unknown document: " + encodedName); + } + }; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/security/AuthenticationUser.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/security/AuthenticationUser.java new file mode 100644 index 0000000000..1cd71a096e --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/security/AuthenticationUser.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.spring.security; + +import com.yahoo.elide.core.security.User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +/** + * Elide User object for Spring Boot. + */ +public class AuthenticationUser extends User { + private Authentication authentication; + + public AuthenticationUser(Authentication authentication) { + super(authentication); + this.authentication = authentication; + } + + @Override + public boolean isInRole(String role) { + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch(role::equals); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/elide-spring/elide-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000000..2070ae5d5a --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -0,0 +1,9 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + com.yahoo.elide.spring.config.ElideAutoConfiguration, \ + com.yahoo.elide.spring.config.ElideAsyncConfiguration, \ + com.yahoo.elide.spring.config.ElideSubscriptionConfiguration, \ + com.yahoo.elide.spring.config.ElideSubscriptionScanningConfiguration, \ + com.yahoo.elide.spring.controllers.JsonApiController, \ + com.yahoo.elide.spring.controllers.GraphqlController, \ + com.yahoo.elide.spring.controllers.SwaggerController, \ + com.yahoo.elide.spring.controllers.ExportController diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/App.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/App.java new file mode 100644 index 0000000000..2e0114835f --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/App.java @@ -0,0 +1,31 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import lombok.extern.slf4j.Slf4j; + +import java.util.TimeZone; +import javax.annotation.PostConstruct; + +/** + * Example app using elide-spring. + */ +@Slf4j +@SpringBootApplication +@EntityScan +public class App { + public static void main(String[] args) throws Exception { + SpringApplication.run(App.class, args); + } + + @PostConstruct + public void init() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/SecurityConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/SecurityConfiguration.java new file mode 100644 index 0000000000..929febefdc --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/SecurityConfiguration.java @@ -0,0 +1,25 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +/** + * Disables security for testing. + */ +@Configuration +@EnableWebSecurity +public class SecurityConfiguration extends WebSecurityConfigurerAdapter { + + @Override + protected void configure(HttpSecurity httpSecurity) throws Exception { + httpSecurity.authorizeRequests().antMatchers("/**").permitAll().and().csrf().disable(); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/checks/AdminCheck.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/checks/AdminCheck.java new file mode 100644 index 0000000000..ccb769a44d --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/checks/AdminCheck.java @@ -0,0 +1,26 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.checks; + +import com.yahoo.elide.annotation.SecurityCheck; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.security.checks.OperationCheck; + +import java.util.Optional; + +@SecurityCheck(AdminCheck.USER_IS_ADMIN) +public class AdminCheck extends OperationCheck { + + public static final String USER_IS_ADMIN = "User is Admin"; + + @Override + public boolean ok(Object object, RequestScope requestScope, Optional optional) { + + //There are no admins... + return false; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/aggregation/Stats.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/aggregation/Stats.java new file mode 100644 index 0000000000..13f7abc49a --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/aggregation/Stats.java @@ -0,0 +1,42 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.models.aggregation; + +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.datastores.aggregation.annotation.MetricFormula; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.VersionQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import javax.persistence.Id; + +@Include +@EqualsAndHashCode +@ToString +@FromTable(name = "stats") +@VersionQuery(sql = "SELECT COUNT(*) FROM stats") +@Data +public class Stats { + + /** + * PK. + */ + @Id + private String id; + + /** + * A metric. + */ + @MetricFormula("SUM({{$measure}})") + private long measure; + + /** + * A degenerate dimension. + */ + private String dimension; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactGroup.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactGroup.java new file mode 100644 index 0000000000..69ed5b6cf3 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactGroup.java @@ -0,0 +1,43 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.models.jpa; + +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.UpdatePermission; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; +import example.checks.AdminCheck; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.OneToMany; + +@Include(name = "group") +@Entity +@Data +@Subscription +public class ArtifactGroup { + @Id + private String name = ""; + + @SubscriptionField + private String commonName = ""; + + private String description = ""; + + @CreatePermission(expression = AdminCheck.USER_IS_ADMIN) + @UpdatePermission(expression = AdminCheck.USER_IS_ADMIN) + @SubscriptionField + private boolean deprecated = false; + + @SubscriptionField + @OneToMany(mappedBy = "group") + private List products = new ArrayList<>(); +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactProduct.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactProduct.java new file mode 100644 index 0000000000..b0c393f53f --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactProduct.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 example.models.jpa; + +import com.yahoo.elide.annotation.Include; + +import java.util.ArrayList; +import java.util.List; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; + +@Include(name = "product", rootLevel = false) +@Entity +public class ArtifactProduct { + @Id + private String name = ""; + + private String commonName = ""; + + private String description = ""; + + @ManyToOne + private ArtifactGroup group = null; + + @OneToMany(mappedBy = "artifact") + private List versions = new ArrayList<>(); +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactVersion.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactVersion.java new file mode 100644 index 0000000000..d046fa9553 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactVersion.java @@ -0,0 +1,25 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.models.jpa; + +import com.yahoo.elide.annotation.Include; + +import java.util.Date; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.ManyToOne; + +@Include(name = "version", rootLevel = false) +@Entity +public class ArtifactVersion { + @Id + private String name = ""; + + private Date createdAt = new Date(); + + @ManyToOne + private ArtifactProduct artifact; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v2/ArtifactGroupV2.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v2/ArtifactGroupV2.java new file mode 100644 index 0000000000..d924ce5ec8 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v2/ArtifactGroupV2.java @@ -0,0 +1,27 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.models.jpa.v2; + +import com.yahoo.elide.annotation.Include; +import lombok.Data; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +@Include(name = "group") +@Entity +@Data +@Table(name = "ArtifactGroup") +public class ArtifactGroupV2 { + @Id + private String name = ""; + + @Column(name = "commonName") + private String title = ""; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v2/package-info.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v2/package-info.java new file mode 100644 index 0000000000..bb06bbfd19 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/v2/package-info.java @@ -0,0 +1,12 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +/** + * Models Package V2. + */ +@ApiVersion(version = "1.0") +package example.models.jpa.v2; + +import com.yahoo.elide.annotation.ApiVersion; diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AggregationStoreTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AggregationStoreTest.java new file mode 100644 index 0000000000..ddabf7bb4a --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AggregationStoreTest.java @@ -0,0 +1,139 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.core.exceptions.HttpStatus; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.jdbc.Sql; +import io.micrometer.core.instrument.MeterRegistry; + +import java.util.HashMap; +import java.util.Map; + +/** + * Example functional tests for Aggregation Store. + */ +@Sql( + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + scripts = "classpath:db/test_init.sql", + statements = { + "INSERT INTO Stats (id, measure, dimension) VALUES\n" + + "\t\t(1,100,'Foo')," + + "\t\t(2,150,'Bar');" +}) +public class AggregationStoreTest extends IntegrationTest { + + /** + * This test demonstrates an example test using the aggregation store. + */ + @Test + public void jsonApiGetTestNoHeader(@Autowired MeterRegistry metrics) { + when() + .get("/json/stats?fields[stats]=measure") + .then() + .body(equalTo( + data( + resource( + type("stats"), + id("0"), + attributes( + attr("measure", 250) + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_OK); + when() + .get("/json/stats?fields[stats]=measure") + .then() + .body(equalTo( + data( + resource( + type("stats"), + id("0"), + attributes( + attr("measure", 250) + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_OK); + assertTrue(metrics + .get("cache.gets") + .tags("cache", "elideQueryCache", "result", "hit") + .functionCounter().count() > 0); + } + + /** + * This test demonstrates an example test using the aggregation store. + */ + @Test + public void jsonApiGetTest(@Autowired MeterRegistry metrics) { + Map requestHeaders = new HashMap<>(); + requestHeaders.put("bypassCache", "true"); + HttpHeaders headers = new HttpHeaders(); + headers.set("bypassCache", "true"); + given().headers(headers) + .get("/json/stats?fields[stats]=measure") + .then() + .body(equalTo( + data( + resource( + type("stats"), + id("0"), + attributes( + attr("measure", 250) + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_OK); + + given().headers(requestHeaders) + .get("/json/stats?fields[stats]=measure") + .then() + .body(equalTo( + data( + resource( + type("stats"), + id("0"), + attributes( + attr("measure", 250) + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_OK); + assertFalse(metrics + .get("cache.gets") + .tags("cache", "elideQueryCache", "result", "hit") + .functionCounter().count() > 0); + } + + @Test + public void metaDataTest() { + given() + .accept("application/vnd.api+json") + .get("/json/namespace/default") //"default" namespace added by Agg Store + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.attributes.name", equalTo("default")) + .body("data.attributes.friendlyName", equalTo("default")); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncDisableExportControllerTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncDisableExportControllerTest.java new file mode 100644 index 0000000000..08f27a7d33 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncDisableExportControllerTest.java @@ -0,0 +1,32 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import static io.restassured.RestAssured.when; +import com.yahoo.elide.core.exceptions.HttpStatus; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.TestPropertySource; + +/** + * Executes Export Controller tests with Async Disabled. + */ +@TestPropertySource( + properties = { + "elide.async.enabled=false" + } +) +public class AsyncDisableExportControllerTest extends IntegrationTest { + + @Test + public void exportControllerTest() { + // A post to export will result in not found, if controller was disabled. + // If controller is enabled, it returns Method Not Allowed. + when() + .post("/export/1") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java new file mode 100644 index 0000000000..d95841566c --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java @@ -0,0 +1,293 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.yahoo.elide.core.exceptions.HttpStatus; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlMergeMode; +import io.restassured.response.Response; + +import javax.ws.rs.core.MediaType; + +/** + * Basic functional tests to test Async service setup, JSONAPI and GRAPHQL endpoints. + */ +@SqlMergeMode(SqlMergeMode.MergeMode.MERGE) +@Sql( + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + scripts = "classpath:db/test_init.sql", + statements = { + "INSERT INTO ArtifactGroup (name, commonName, description, deprecated) VALUES\n" + + "\t\t('com.example.repository','Example Repository','The code for this project', false);", + "INSERT INTO Stats (id, measure, dimension) VALUES\n" + + "\t\t(1,100,'Foo')," + + "\t\t(2,150,'Bar');", + "INSERT INTO PlayerStats (name, highScore, countryId, createdOn, updatedOn) VALUES\n" + + "\t\t('Sachin',100, 1, '2020-01-01', now());", + "INSERT INTO PlayerCountry (id, isoCode) VALUES\n" + + "\t\t(1, 'IND');" +}) +public class AsyncTest extends IntegrationTest { + + @Test + public void testAsyncApiEndpoint() throws InterruptedException { + //Create Async Request + given() + .contentType(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("asyncQuery"), + id("ba31ca4e-ed8f-4be0-a0f3-12088fa9263d"), + attributes( + attr("query", "/group"), + attr("queryType", "JSONAPI_V1_0"), + attr("status", "QUEUED"), + attr("asyncAfterSeconds", "10") + ) + ) + ).toJSON()) + .when() + .post("/json/asyncQuery") + .then() + .statusCode(org.apache.http.HttpStatus.SC_CREATED); + + int i = 0; + while (i < 1000) { + Thread.sleep(10); + Response response = given() + .accept("application/vnd.api+json") + .get("/json/asyncQuery/ba31ca4e-ed8f-4be0-a0f3-12088fa9263d"); + + String outputResponse = response.jsonPath().getString("data.attributes.status"); + + //If Async Query is created and completed then validate results + if (outputResponse.equals("COMPLETE")) { + + // Validate AsyncQuery Response + response + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.id", equalTo("ba31ca4e-ed8f-4be0-a0f3-12088fa9263d")) + .body("data.type", equalTo("asyncQuery")) + .body("data.attributes.queryType", equalTo("JSONAPI_V1_0")) + .body("data.attributes.status", equalTo("COMPLETE")) + .body("data.attributes.result.contentLength", notNullValue()) + .body("data.attributes.result.responseBody", equalTo("{\"data\":" + + "[{\"type\":\"group\",\"id\":\"com.example.repository\",\"attributes\":" + + "{\"commonName\":\"Example Repository\",\"deprecated\":false,\"description\":\"The code for this project\"}," + + "\"relationships\":{\"products\":{\"data\":[]}}}]}")); + + // Validate GraphQL Response + String responseGraphQL = given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{\"query\":\"{ asyncQuery(ids: [\\\"ba31ca4e-ed8f-4be0-a0f3-12088fa9263d\\\"]) " + + "{ edges { node { id queryType status result " + + "{ responseBody httpStatus contentLength } } } } }\"," + + "\"variables\":null }") + .post("/graphql") + .asString(); + + String expectedResponse = "{\"data\":{\"asyncQuery\":{\"edges\":[{\"node\":{\"id\":\"ba31ca4e-ed8f-4be0-a0f3-12088fa9263d\",\"queryType\":\"JSONAPI_V1_0\",\"status\":\"COMPLETE\",\"result\":{\"responseBody\":\"{\\\"data\\\":[{\\\"type\\\":\\\"group\\\",\\\"id\\\":\\\"com.example.repository\\\",\\\"attributes\\\":{\\\"commonName\\\":\\\"Example Repository\\\",\\\"deprecated\\\":false,\\\"description\\\":\\\"The code for this project\\\"},\\\"relationships\\\":{\\\"products\\\":{\\\"data\\\":[]}}}]}\",\"httpStatus\":200,\"contentLength\":208}}}]}}}"; + + assertEquals(expectedResponse, responseGraphQL); + break; + } + assertEquals("PROCESSING", outputResponse, "Async Query has failed."); + } + } + + @Test + public void testExportDynamicModel() throws InterruptedException { + //Create Table Export + given() + .contentType(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("tableExport"), + id("ba31ca4e-ed8f-4be0-a0f3-12088fa9265d"), + attributes( + attr("query", "{\"query\":\"{playerStats(filter:\\\"createdOn>=2020-01-01;createdOn<2020-01-02\\\"){ edges{node{countryCode highScore}}}}\",\"variables\":null}"), + attr("queryType", "GRAPHQL_V1_0"), + attr("status", "QUEUED"), + attr("asyncAfterSeconds", "10"), + attr("resultType", "CSV") + ) + ) + ).toJSON()) + .when() + .post("/json/tableExport") + .then() + .statusCode(org.apache.http.HttpStatus.SC_CREATED); + + int i = 0; + while (i < 1000) { + Thread.sleep(10); + Response response = given() + .accept("application/vnd.api+json") + .get("/json/tableExport/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d"); + + String outputResponse = response.jsonPath().getString("data.attributes.status"); + + //If Async Query is created and completed then validate results + if (outputResponse.equals("COMPLETE")) { + + // Validate AsyncQuery Response + response + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.id", equalTo("ba31ca4e-ed8f-4be0-a0f3-12088fa9265d")) + .body("data.type", equalTo("tableExport")) + .body("data.attributes.queryType", equalTo("GRAPHQL_V1_0")) + .body("data.attributes.status", equalTo("COMPLETE")) + .body("data.attributes.result.message", equalTo(null)) + .body("data.attributes.result.url", + equalTo("https://elide.io" + "/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d.csv")); + + // Validate GraphQL Response + String responseGraphQL = given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{\"query\":\"{ tableExport(ids: [\\\"ba31ca4e-ed8f-4be0-a0f3-12088fa9265d\\\"]) " + + "{ edges { node { id queryType status resultType result " + + "{ url httpStatus recordCount } } } } }\"," + + "\"variables\":null }") + .post("/graphql") + .asString(); + + String expectedResponse = "{\"data\":{\"tableExport\":{\"edges\":[{\"node\":{\"id\":\"ba31ca4e-ed8f-4be0-a0f3-12088fa9265d\"," + + "\"queryType\":\"GRAPHQL_V1_0\",\"status\":\"COMPLETE\",\"resultType\":\"CSV\"," + + "\"result\":{\"url\":\"https://elide.io/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d.csv\",\"httpStatus\":200,\"recordCount\":1}}}]}}}"; + + assertEquals(expectedResponse, responseGraphQL); + break; + } + assertEquals("PROCESSING", outputResponse, "Async Query has failed."); + } + when() + .get("/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d.csv") + .then() + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void testExportStaticModel() throws InterruptedException { + //Create Table Export + given() + .contentType(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("tableExport"), + id("ba31ca4e-ed8f-4be0-a0f3-12088fa9264d"), + attributes( + attr("query", "{\"query\":\"{ stats { edges { node { dimension measure } } } }\",\"variables\":null}"), + attr("queryType", "GRAPHQL_V1_0"), + attr("status", "QUEUED"), + attr("asyncAfterSeconds", "10"), + attr("resultType", "CSV") + ) + ) + ).toJSON()) + .when() + .post("/json/tableExport") + .then() + .statusCode(org.apache.http.HttpStatus.SC_CREATED); + + int i = 0; + while (i < 1000) { + Thread.sleep(10); + Response response = given() + .accept("application/vnd.api+json") + .get("/json/tableExport/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d"); + + String outputResponse = response.jsonPath().getString("data.attributes.status"); + + //If Async Query is created and completed then validate results + if (outputResponse.equals("COMPLETE")) { + + // Validate AsyncQuery Response + response + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.id", equalTo("ba31ca4e-ed8f-4be0-a0f3-12088fa9264d")) + .body("data.type", equalTo("tableExport")) + .body("data.attributes.queryType", equalTo("GRAPHQL_V1_0")) + .body("data.attributes.status", equalTo("COMPLETE")) + .body("data.attributes.result.message", equalTo(null)) + .body("data.attributes.result.url", + equalTo("https://elide.io" + "/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d.csv")); + + // Validate GraphQL Response + String responseGraphQL = given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{\"query\":\"{ tableExport(ids: [\\\"ba31ca4e-ed8f-4be0-a0f3-12088fa9264d\\\"]) " + + "{ edges { node { id queryType status resultType result " + + "{ url httpStatus recordCount } } } } }\"," + + "\"variables\":null }") + .post("/graphql") + .asString(); + + String expectedResponse = "{\"data\":{\"tableExport\":{\"edges\":[{\"node\":{\"id\":\"ba31ca4e-ed8f-4be0-a0f3-12088fa9264d\"," + + "\"queryType\":\"GRAPHQL_V1_0\",\"status\":\"COMPLETE\",\"resultType\":\"CSV\"," + + "\"result\":{\"url\":\"https://elide.io/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d.csv\",\"httpStatus\":200,\"recordCount\":2}}}]}}}"; + + assertEquals(expectedResponse, responseGraphQL); + break; + } + assertEquals("PROCESSING", outputResponse, "Async Query has failed."); + } + when() + .get("/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d.csv") + .then() + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void exportControllerTest() { + when() + .get("/export/asyncQueryId") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + } + + @Test + public void postExportControllerTest() { + when() + .post("/export/asyncQueryId") + .then() + .statusCode(HttpStatus.SC_METHOD_NOT_ALLOWED); + } + + @Test + public void swaggerDocumentTest() { + when() + .get("/doc") + .then() + .statusCode(HttpStatus.SC_OK) + .body("tags.name", containsInAnyOrder("group", "argument", "metric", + "dimension", "column", "table", "asyncQuery", + "timeDimensionGrain", "timeDimension", "product", "playerCountry", "version", "playerStats", + "stats", "tableExport", "namespace", "tableSource")); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreIntegrationTestSetup.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreIntegrationTestSetup.java new file mode 100644 index 0000000000..fcfbb13696 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreIntegrationTestSetup.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.Injector; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.modelconfig.DynamicConfiguration; +import com.yahoo.elide.modelconfig.store.models.ConfigChecks; +import com.yahoo.elide.spring.config.ElideConfigProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@TestConfiguration +public class ConfigStoreIntegrationTestSetup { + + @Bean + public EntityDictionary buildDictionary(AutowireCapableBeanFactory beanFactory, + ClassScanner scanner, + @Autowired(required = false) DynamicConfiguration dynamicConfig, + ElideConfigProperties settings, + @Qualifier("entitiesToExclude") Set> entitiesToExclude) { + + Map> checks = new HashMap<>(); + + if (settings.getDynamicConfig().isConfigApiEnabled()) { + checks.put(ConfigChecks.CAN_CREATE_CONFIG, ConfigChecks.CanCreate.class); + checks.put(ConfigChecks.CAN_READ_CONFIG, ConfigChecks.CanRead.class); + checks.put(ConfigChecks.CAN_DELETE_CONFIG, ConfigChecks.CanDelete.class); + checks.put(ConfigChecks.CAN_UPDATE_CONFIG, ConfigChecks.CanNotUpdate.class); + } + + EntityDictionary dictionary = new EntityDictionary( + checks, //Checks + new HashMap<>(), //Role Checks + new Injector() { + @Override + public void inject(Object entity) { + beanFactory.autowireBean(entity); + } + + @Override + public T instantiate(Class cls) { + return beanFactory.createBean(cls); + } + }, + CoerceUtil::lookup, //Serde Lookup + entitiesToExclude, + scanner); + + return dictionary; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreTest.java new file mode 100644 index 0000000000..d0c77e4519 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreTest.java @@ -0,0 +1,719 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import static com.yahoo.elide.test.graphql.GraphQLDSL.argument; +import static com.yahoo.elide.test.graphql.GraphQLDSL.arguments; +import static com.yahoo.elide.test.graphql.GraphQLDSL.field; +import static com.yahoo.elide.test.graphql.GraphQLDSL.mutation; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selection; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selections; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.equalTo; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType; +import com.yahoo.elide.spring.controllers.JsonApiController; +import com.yahoo.elide.test.graphql.GraphQLDSL; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import io.restassured.RestAssured; +import lombok.Builder; +import lombok.Data; + +import java.nio.file.Path; +import java.util.TimeZone; +import javax.ws.rs.core.MediaType; + +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(ConfigStoreIntegrationTestSetup.class) +@TestPropertySource( + properties = { + "elide.dynamic-config.configApiEnabled=true" + } +) +public class ConfigStoreTest { + + @Data + @Builder + public static class ConfigFile { + ConfigFileType type; + + String path; + + String content; + } + + @LocalServerPort + protected int port; + + @BeforeAll + public static void initialize(@TempDir Path testDirectory) { + System.setProperty("elide.dynamic-config.path", testDirectory.toFile().getAbsolutePath()); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @AfterAll + public static void cleanup() { + System.clearProperty("elide.dynamic-config.path"); + } + + /** + * Empty configuration load test. + */ + @Test + public void testEmptyConfiguration() { + when() + .get("http://localhost:" + port + "/json/config?fields[config]=path,type") + .then() + .body(equalTo("{\"data\":[]}")) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void testGraphQLNullContent() { + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "UPSERT"), + argument("data", "{ type: TABLE, path: \\\"models/tables/table1.hjson\\\" }") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"errors\":[{\"message\":\"Null or empty file content for models/tables/table1.hjson\"}]}")) + .statusCode(200); + } + + @Test + public void testGraphQLCreateFetchAndDelete() { + String hjson = "\\\"{\\\\n" + + " tables: [{\\\\n" + + " name: Test\\\\n" + + " table: test\\\\n" + + " schema: test\\\\n" + + " measures : [\\\\n" + + " {\\\\n" + + " name : measure\\\\n" + + " type : INTEGER\\\\n" + + " definition: 'MAX({{$measure}})'\\\\n" + + " }\\\\n" + + " ]\\\\n" + + " dimensions : [\\\\n" + + " {\\\\n" + + " name : dimension\\\\n" + + " type : TEXT\\\\n" + + " definition : '{{$dimension}}'\\\\n" + + " }\\\\n" + + " ]\\\\n" + + " }]\\\\n" + + "}\\\""; + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "UPSERT"), + argument("data", String.format("{ type: TABLE, path: \\\"models/tables/table1.hjson\\\", content: %s }", hjson)) + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo( + GraphQLDSL.document( + selection( + field( + "config", + selections( + field("id", "bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + field("path", "models/tables/table1.hjson") + ) + ) + ) + ).toResponse() + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + selection( + field("config", + argument( + argument("ids", "[\\\"bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo( + GraphQLDSL.document( + selection( + field( + "config", + selections( + field("id", "bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + field("path", "models/tables/table1.hjson") + ) + ) + ) + ).toResponse() + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "DELETE"), + argument("ids", "[\\\"bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + selection( + field("config", + selections( + field("id"), + field("path") + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + } + + @Test + public void testTwoNamespaceCreateAndDelete() { + String hjson1 = "\\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace2\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\""; + + String hjson2 = "\\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace3\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\""; + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "UPSERT"), + argument("data", String.format("[" + + "{ type: NAMESPACE, path: \\\"models/namespaces/namespace2.hjson\\\", content: %s }," + + "{ type: NAMESPACE, path: \\\"models/namespaces/namespace3.hjson\\\", content: %s }" + + "]" , hjson1, hjson2)) + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo( + GraphQLDSL.document( + selection( + field( + "config", + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMi5oanNvbg=="), + field("path", "models/namespaces/namespace2.hjson") + ), + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMy5oanNvbg=="), + field("path", "models/namespaces/namespace3.hjson") + ) + ) + ) + ).toResponse() + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "DELETE"), + argument("ids", "[\\\"bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMi5oanNvbg==\\\", \\\"bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMy5oanNvbg==\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + + } + + @Test + public void testTwoNamespaceCreationStatements() { + String query = "{ \"query\": \" mutation saveChanges {\\n one: config(op: UPSERT, data: {id:\\\"one\\\", path: \\\"models/namespaces/oneDemoNamespaces.hjson\\\", type: NAMESPACE, content: \\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace2\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\"}) {\\n edges {\\n node {\\n id\\n }\\n }\\n }\\n two: config(op: UPSERT, data: {id: \\\"two\\\", path: \\\"models/namespaces/twoDemoNamespaces.hjson\\\", type: NAMESPACE, content: \\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace3\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\"}) {\\n edges {\\n node {\\n id\\n }\\n }\\n }\\n} \" }"; + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(query) + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo( + GraphQLDSL.document( + selections( + field( + "one", + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvb25lRGVtb05hbWVzcGFjZXMuaGpzb24=") + ) + ), + field( + "two", + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvdHdvRGVtb05hbWVzcGFjZXMuaGpzb24=") + ) + ) + ) + ).toResponse().replace(" ", "") + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "DELETE"), + argument("ids", "[\\\"bW9kZWxzL25hbWVzcGFjZXMvb25lRGVtb05hbWVzcGFjZXMuaGpzb24=\\\", \\\"bW9kZWxzL25hbWVzcGFjZXMvdHdvRGVtb05hbWVzcGFjZXMuaGpzb24=\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + + } + + @Test + public void testJsonApiCreateFetchAndDelete() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .statusCode(HttpStatus.SC_CREATED); + + when() + .get("http://localhost:" + port + "/json/config?fields[config]=content") + .then() + .body(equalTo(data( + resource( + type("config"), + id("bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + attributes( + attr("content", hjson) + ) + ) + ).toJSON())) + .statusCode(HttpStatus.SC_OK); + + when() + .delete("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + when() + .get("http://localhost:" + port + "/json/config?fields[config]=path,type") + .then() + .body(equalTo("{\"data\":[]}")) + .statusCode(HttpStatus.SC_OK); + + when() + .get("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + } + + @Test + public void testUpdatePermissionError() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .statusCode(HttpStatus.SC_CREATED); + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + id("bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .patch("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN); + + when() + .delete("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + } + + @Test + public void testTemplateError() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}}) + {{$$column.args.missing}}'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .body(equalTo("{\"errors\":[{\"detail\":\"Failed to verify column arguments for column: measure in table: Test. Argument 'missing' is not defined but found '{{$$column.args.missing}}'.\"}]}")) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + @Test + public void testPathUpdatePermissionError() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .statusCode(HttpStatus.SC_CREATED); + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + id("bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + attributes( + attr("path", "models/tables/newName.hjson") + ) + ) + ) + ) + .when() + .patch("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN); + + when() + .delete("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + } + + @Test + public void testHackAttempt() { + String hjson = "#!/bin/sh ..."; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "foo"), + attr("type", "UNKNOWN"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .body(equalTo("{\"errors\":[{\"detail\":\"Unrecognized File: foo\"}]}")) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + @Test + public void testPathTraversalAttempt() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "../../../../../tmp/models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .body(equalTo("{\"errors\":[{\"detail\":\"Parent directory traversal not allowed: ../../../../../tmp/models/tables/table1.hjson\"}]}")) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java new file mode 100644 index 0000000000..d854bb7423 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java @@ -0,0 +1,480 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import static com.yahoo.elide.test.graphql.GraphQLDSL.argument; +import static com.yahoo.elide.test.graphql.GraphQLDSL.arguments; +import static com.yahoo.elide.test.graphql.GraphQLDSL.field; +import static com.yahoo.elide.test.graphql.GraphQLDSL.mutation; +import static com.yahoo.elide.test.graphql.GraphQLDSL.query; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selection; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selections; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.linkage; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.links; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.patchOperation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.patchSet; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relationships; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static com.yahoo.elide.test.jsonapi.elements.PatchOperationType.add; +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.spring.controllers.JsonApiController; +import com.yahoo.elide.test.graphql.GraphQLDSL; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlMergeMode; + +import javax.ws.rs.core.MediaType; + +/** + * Example functional test. + */ +@SqlMergeMode(SqlMergeMode.MergeMode.MERGE) +@Sql( + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + scripts = "classpath:db/test_init.sql", + statements = "INSERT INTO ArtifactGroup (name, commonName, description, deprecated) VALUES\n" + + "\t\t('com.example.repository','Example Repository','The code for this project', false);" +) +@TestPropertySource( + properties = { + "elide.json-api.enableLinks=true", + "elide.async.export.enabled=false", + } +) +@ActiveProfiles("default") +public class ControllerTest extends IntegrationTest { + private String baseUrl; + + @BeforeAll + @Override + public void setUp() { + super.setUp(); + baseUrl = "https://elide.io/json/"; + } + + /** + * This test demonstrates an example test using the JSON-API DSL. + */ + @Test + public void jsonApiGetTest() { + when() + .get("/json/group") + .then() + .body(equalTo( + data( + resource( + type("group"), + id("com.example.repository"), + attributes( + attr("commonName", "Example Repository"), + attr("deprecated", false), + attr("description", "The code for this project") + ), + links( + attr("self", baseUrl + "group/com.example.repository") + ), + relationships( + relation( + "products", + links( + attr("self", baseUrl + "group/com.example.repository/relationships/products"), + attr("related", baseUrl + "group/com.example.repository/products") + ) + ) + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void versionedJsonApiGetTest() { + given() + .header("ApiVersion", "1.0") + .when() + .get("/json/group") + .then() + .body(equalTo( + data( + resource( + type("group"), + id("com.example.repository"), + attributes( + attr("title", "Example Repository") + ), + links( + attr("self", baseUrl + "group/com.example.repository") + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void jsonApiPatchTest() { + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("group"), + id("com.example.repository"), + attributes( + attr("commonName", "Changed It.") + ) + ) + ) + ) + .when() + .patch("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + + when() + .get("/json/group") + .then() + .body(equalTo( + data( + resource( + type("group"), + id("com.example.repository"), + attributes( + attr("commonName", "Changed It."), + attr("deprecated", false), + attr("description", "The code for this project") + ), + links( + attr("self", baseUrl + "group/com.example.repository") + ), + relationships( + relation( + "products", + links( + attr("self", baseUrl + "group/com.example.repository/relationships/products"), + attr("related", baseUrl + "group/com.example.repository/products") + ) + + ) + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void jsonForbiddenApiPatchTest() { + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("group"), + id("com.example.repository"), + attributes( + attr("commonName", "Changed It."), + attr("deprecated", true) + ) + ) + ) + ) + .when() + .patch("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN); + } + + @Test + public void jsonApiPatchExtensionTest() { + given() + .contentType(JsonApiController.JSON_API_PATCH_CONTENT_TYPE) + .accept(JsonApiController.JSON_API_PATCH_CONTENT_TYPE) + .body( + patchSet( + patchOperation(add, "/group", + resource( + type("group"), + id("com.example.repository.foo"), + attributes( + attr("commonName", "Foo") + ) + ) + ) + ) + ) + .when() + .patch("/json") + .then() + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void jsonApiPostTest() { + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("group"), + id("com.example.repository2"), + attributes( + attr("commonName", "New group.") + ) + ) + ) + ) + .when() + .post("/json/group") + .then() + .body(equalTo(datum( + resource( + type("group"), + id("com.example.repository2"), + attributes( + attr("commonName", "New group."), + attr("deprecated", false), + attr("description", "") + ), + links( + attr("self", baseUrl + "group/com.example.repository2") + ), + relationships( + relation( + "products", + links( + attr("self", baseUrl + "group/com.example.repository2/relationships/products"), + attr("related", baseUrl + "group/com.example.repository2/products") + ) + ) + ) + ) + ).toJSON())) + .statusCode(HttpStatus.SC_CREATED); + } + + @Test + public void jsonApiDeleteTest() { + when() + .delete("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + } + + @Test + @Sql(statements = { + "INSERT INTO ArtifactProduct (name, commonName, description, group_name) VALUES\n" + + "\t\t('foo','foo Core','The guts of foo','com.example.repository');" + }) + public void jsonApiDeleteRelationshipTest() { + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body(datum( + linkage(type("product"), id("foo")) + )) + .when() + .delete("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + } + + /** + * This test demonstrates an example test using the GraphQL DSL. + */ + @Test + public void graphqlTest() { + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + query( + selection( + field("group", + selections( + field("name"), + field("commonName"), + field("description") + ) + ) + ) + ) + ).toQuery() + "\" }" + ) + .when() + .post("/graphql") + .then() + .body(equalTo(GraphQLDSL.document( + selection( + field( + "group", + selections( + field("name", "com.example.repository"), + field("commonName", "Example Repository"), + field("description", "The code for this project") + ) + ) + ) + ).toResponse())) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void testInvalidApiVersion() { + + String graphQLRequest = GraphQLDSL.document( + selection( + field( + "group", + selections( + field("name") + ) + ) + ) + ).toQuery(); + + String expected = "{\"errors\":[{\"message\":\"Invalid operation: Invalid API Version\"}]}"; + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header("ApiVersion", "2.0") + .body("{ \"query\" : \"" + graphQLRequest + "\" }") + .post("/graphql") + .then() + .body(equalTo(expected)) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + /** + * This test demonstrates an example test using the GraphQL DSL. + */ + @Test + public void versionedGraphqlTest() { + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .header("ApiVersion", "1.0") + .body("{ \"query\" : \"" + GraphQLDSL.document( + query( + selection( + field("group", + selections( + field("name"), + field("title") + ) + ) + ) + ) + ).toQuery() + "\" }" + ) + .when() + .post("/graphql") + .then() + .body(equalTo(GraphQLDSL.document( + selection( + field( + "group", + selections( + field("name", "com.example.repository"), + field("title", "Example Repository") + ) + ) + ) + ).toResponse())) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void swaggerDocumentTest() { + when() + .get("/doc") + .then() + .statusCode(HttpStatus.SC_OK) + .body("tags.name", containsInAnyOrder("group", "argument", "metric", + "dimension", "column", "table", "asyncQuery", + "timeDimensionGrain", "timeDimension", "product", "playerCountry", "version", "playerStats", + "stats", "namespace", "tableSource")); + } + + @Test + public void versionedSwaggerDocumentTest() { + given() + .header("ApiVersion", "1.0") + .when() + .get("/doc") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo("[]")); + } + + @Test + public void swaggerXSSDocumentTest() { + when() + .get("/doc/