From c1ef90e68f2287383feae156aa217a67012aa1b2 Mon Sep 17 00:00:00 2001 From: Edward Park Date: Thu, 17 Mar 2022 12:11:39 -0700 Subject: [PATCH 1/4] move existing demo apps to v4 and create v5 --- java/.gitignore | 11 - java/LICENSE | 201 ------ java/README.md | 31 - java/build.gradle | 52 -- java/gradle/wrapper/gradle-wrapper.jar | Bin 55190 -> 0 bytes java/gradle/wrapper/gradle-wrapper.properties | 5 - java/gradlew | 172 ------ java/gradlew.bat | 84 --- java/settings.gradle | 10 - java/src/main/java/api/example/java/App.java | 11 - .../main/java/api/example/java/Config.java | 104 ---- .../api/example/java/api/ClimateAPIs.java | 100 --- .../api/example/java/api/ClimateOAuth.java | 59 -- .../api/example/java/api/RequestClient.java | 40 -- .../controllers/AgronomicDataController.java | 81 --- .../java/controllers/BaseController.java | 62 -- .../java/controllers/FieldController.java | 29 - .../java/controllers/HomeController.java | 28 - .../java/controllers/LoginController.java | 45 -- .../java/api/example/java/model/Activity.java | 70 --- .../example/java/model/ActivityResult.java | 17 - .../java/api/example/java/model/Field.java | 50 -- .../api/example/java/model/FieldResult.java | 16 - .../java/api/example/java/model/Parent.java | 23 - .../api/example/java/model/TokenResponse.java | 50 -- .../java/api/example/java/model/User.java | 42 -- .../src/main/resources/application.properties | 12 - java/src/main/resources/static/favicon.png | Bin 7054 -> 0 bytes .../main/resources/static/fv-login-button.png | Bin 2314 -> 0 bytes .../main/resources/templates/activities.ftl | 34 -- java/src/main/resources/templates/fields.ftl | 28 - java/src/main/resources/templates/footer.ftl | 28 - java/src/main/resources/templates/home.ftl | 18 - java/src/main/resources/templates/index.ftl | 13 - .../main/resources/templates/standardPage.ftl | 29 - .../test/java/api/example/java/AppTest.java | 11 - python/.gitignore | 92 --- python/LICENSE | 201 ------ python/README.md | 55 -- python/climate.py | 568 ----------------- python/file.py | 28 - python/logger.py | 28 - python/main.py | 574 ------------------ python/requirements.txt | 8 - python/res/fv-login-button.png | Bin 2314 -> 0 bytes 45 files changed, 3120 deletions(-) delete mode 100644 java/.gitignore delete mode 100644 java/LICENSE delete mode 100644 java/README.md delete mode 100644 java/build.gradle delete mode 100644 java/gradle/wrapper/gradle-wrapper.jar delete mode 100644 java/gradle/wrapper/gradle-wrapper.properties delete mode 100755 java/gradlew delete mode 100644 java/gradlew.bat delete mode 100644 java/settings.gradle delete mode 100644 java/src/main/java/api/example/java/App.java delete mode 100644 java/src/main/java/api/example/java/Config.java delete mode 100644 java/src/main/java/api/example/java/api/ClimateAPIs.java delete mode 100644 java/src/main/java/api/example/java/api/ClimateOAuth.java delete mode 100644 java/src/main/java/api/example/java/api/RequestClient.java delete mode 100644 java/src/main/java/api/example/java/controllers/AgronomicDataController.java delete mode 100644 java/src/main/java/api/example/java/controllers/BaseController.java delete mode 100644 java/src/main/java/api/example/java/controllers/FieldController.java delete mode 100644 java/src/main/java/api/example/java/controllers/HomeController.java delete mode 100644 java/src/main/java/api/example/java/controllers/LoginController.java delete mode 100644 java/src/main/java/api/example/java/model/Activity.java delete mode 100644 java/src/main/java/api/example/java/model/ActivityResult.java delete mode 100644 java/src/main/java/api/example/java/model/Field.java delete mode 100644 java/src/main/java/api/example/java/model/FieldResult.java delete mode 100644 java/src/main/java/api/example/java/model/Parent.java delete mode 100644 java/src/main/java/api/example/java/model/TokenResponse.java delete mode 100644 java/src/main/java/api/example/java/model/User.java delete mode 100644 java/src/main/resources/application.properties delete mode 100644 java/src/main/resources/static/favicon.png delete mode 100644 java/src/main/resources/static/fv-login-button.png delete mode 100644 java/src/main/resources/templates/activities.ftl delete mode 100644 java/src/main/resources/templates/fields.ftl delete mode 100644 java/src/main/resources/templates/footer.ftl delete mode 100644 java/src/main/resources/templates/home.ftl delete mode 100644 java/src/main/resources/templates/index.ftl delete mode 100644 java/src/main/resources/templates/standardPage.ftl delete mode 100644 java/src/test/java/api/example/java/AppTest.java delete mode 100644 python/.gitignore delete mode 100644 python/LICENSE delete mode 100644 python/README.md delete mode 100644 python/climate.py delete mode 100644 python/file.py delete mode 100644 python/logger.py delete mode 100644 python/main.py delete mode 100644 python/requirements.txt delete mode 100644 python/res/fv-login-button.png diff --git a/java/.gitignore b/java/.gitignore deleted file mode 100644 index 2f083d2..0000000 --- a/java/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Ignore Gradle project-specific cache directory -.gradle - -# Ignore Gradle build output directory -build -/bin/ -.DS_Store -.settings -.project -.classpath -.gradle diff --git a/java/LICENSE b/java/LICENSE deleted file mode 100644 index 8be6579..0000000 --- a/java/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2018 The Climate Corporation - - 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. diff --git a/java/README.md b/java/README.md deleted file mode 100644 index d7dc24b..0000000 --- a/java/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# API Example - -Example app exercising some of Climate's [FieldView Platform APIs](https://dev.fieldview.com). - -## Setup -1. Install [Java 8+](https://www.java.com/en/download/) -2. Install [Gradle](https://gradle.org/install/) -3. Set the following environment variables - -```bash -export CLIENT_ID="my-api-id" -export CLIENT_SECRET="azbq56fpadhnt8oukoeani2a4w" -export API_KEY="my-api-id-216b9875-0158-4142-1ab2-7c3bdbd6a2157" -export API_SCOPES="fields:read asPlanted:read" -``` -Regarding scopes - see the [FieldView API technical documentation](https://dev.fieldview.com/technical-documentation/) for more scopes and their -corresponding endpoints (click the `Authorize` button in the swagger docs). - -## Running the web example - -1. Start the server: - -```bash -./gradlew run -``` - -2. Open a browser to [localhost:8080](http://localhost:8080) - -## License - -Copyright © 2019 The Climate Corporation \ No newline at end of file diff --git a/java/build.gradle b/java/build.gradle deleted file mode 100644 index fb4f98b..0000000 --- a/java/build.gradle +++ /dev/null @@ -1,52 +0,0 @@ -/* - * This generated file contains a sample Java project to get you started. - * For more details take a look at the Java Quickstart chapter in the Gradle - * user guide available at https://docs.gradle.org/5.1.1/userguide/tutorial_java_projects.html - */ - -plugins { - // Apply the java plugin to add support for Java - id 'java' - // Apply the application plugin to add support for building an application - id 'application' - //plugin for eclipse web application - id 'eclipse-wtp' - id 'com.gradle.build-scan' version '2.0.2' - // spring boot - id 'org.springframework.boot' version '2.1.2.RELEASE' - id 'io.spring.dependency-management' version '1.0.6.RELEASE' -} -eclipse { - wtp { - facet { - facets = [] - facet name: 'jst.web', version: '2.4' - facet name: 'jst.java', version: '10' - } - } -} - -repositories { - // Use jcenter for resolving your dependencies. - // You can declare any Maven/Ivy/file repository here. - jcenter() - mavenCentral() - maven { url 'https://plugins.gradle.org/m2/' } - maven { url 'https://repo.spring.io/release' } -} - -dependencies { - // This dependency is found on compile classpath of this component and consumers. - implementation 'com.google.guava:guava:26.0-jre' - implementation 'org.springframework.boot:spring-boot-dependencies:2.0.5.RELEASE' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-freemarker' - implementation 'org.springframework.boot:spring-boot-devtools' - implementation 'org.springframework.boot:spring-boot-starter-webflux' - implementation 'org.projectreactor:reactor-spring:1.0.1.RELEASE' - // Use JUnit test framework - testImplementation 'junit:junit:4.12' - -} -// Define the main class for the application -mainClassName = 'api.example.java.App' diff --git a/java/gradle/wrapper/gradle-wrapper.jar b/java/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 87b738cbd051603d91cc39de6cb000dd98fe6b02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 55190 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 z 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 \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - -exec "$JAVACMD" "$@" diff --git a/java/gradlew.bat b/java/gradlew.bat deleted file mode 100644 index 0f8d593..0000000 --- a/java/gradlew.bat +++ /dev/null @@ -1,84 +0,0 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/java/settings.gradle b/java/settings.gradle deleted file mode 100644 index 5b08cf9..0000000 --- a/java/settings.gradle +++ /dev/null @@ -1,10 +0,0 @@ -/* - * This file was generated by the Gradle 'init' task. - * - * The settings file is used to specify which projects to include in your build. - * - * Detailed information about configuring a multi-project build in Gradle can be found - * in the user guide at https://docs.gradle.org/5.1.1/userguide/multi_project_builds.html - */ - -rootProject.name = 'api-example-java' diff --git a/java/src/main/java/api/example/java/App.java b/java/src/main/java/api/example/java/App.java deleted file mode 100644 index 84776aa..0000000 --- a/java/src/main/java/api/example/java/App.java +++ /dev/null @@ -1,11 +0,0 @@ -package api.example.java; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class App { - public static void main(String[] args) { - SpringApplication.run(App.class, args); - } -} diff --git a/java/src/main/java/api/example/java/Config.java b/java/src/main/java/api/example/java/Config.java deleted file mode 100644 index d00bf13..0000000 --- a/java/src/main/java/api/example/java/Config.java +++ /dev/null @@ -1,104 +0,0 @@ -package api.example.java; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.util.Base64Utils; -import org.springframework.util.StringUtils; -import org.springframework.web.util.UriComponentsBuilder; - -@Configuration -public class Config { - @Value("${climate.login.server}") - public String loginServer; - - @Value("${climate.login.path}") - public String loginPath; - - @Value("${climate.token.server}") - public String tokenServer; - - @Value("${climate.token.path}") - public String tokenPath; - - @Value("${climate.api.server}") - public String apiServer; - - @Value("${CLIENT_ID}") - public String clientId; - - @Value("${CLIENT_SECRET}") - public String clientSecret; - - @Value("${API_KEY}") - public String apiKey; - - @Value("${API_SCOPES}") - public String scopes; - - public Config() { - if (StringUtils.isEmpty(System.getenv("CLIENT_ID"))) { - throw new IllegalArgumentException("Please set environment variable CLIENT_ID"); - } - if (StringUtils.isEmpty(System.getenv("CLIENT_SECRET"))) { - throw new IllegalArgumentException("Please set environment variable CLIENT_SECRET"); - } - if (StringUtils.isEmpty(System.getenv("API_KEY"))) { - throw new IllegalArgumentException("Please set environment variable API_KEY"); - } - if (StringUtils.isEmpty(System.getenv("API_SCOPES"))) { - throw new IllegalArgumentException("Please set environment variable API_SCOPES"); - } - } - - public String buildOauthLink(String redirectUri) { - /* - * https://climate.com/static/app-login/index.html?scope=${scope} - * &page=oidcauthn&response_type=code&redirect_uri=${redirect_uri} - * &client_id=${client_id} - */ - return UriComponentsBuilder - .newInstance() - .scheme("https") - .host(loginServer) - .path(loginPath) - .queryParam("page", "oidcauthn") - .queryParam("response_type", "code") - .queryParam("scope", scopes) - .queryParam("client_id", clientId) - .queryParam("redirect_uri", redirectUri) - .build() - .toUriString(); - } - - public String buildTokenUri() { - // https://api.climate.com/api/oauth/token - return getUriComponentsBuilder(tokenServer, tokenPath); - } - - public String buildAgronomicApiUri(String dataType) { - // https://platform.climate.com/v4/layers - return getUriComponentsBuilder(apiServer, "/v4/layers/" + dataType); - } - - public String getBase64Credentials() { - return "Basic " + new String(Base64Utils.encode((clientId + ":" + clientSecret).getBytes())); - } - - public String buildAgronomicContentsApiUri(String id, String dataType) { - // https://platform.climate.com/v4/layers/id/contents - return getUriComponentsBuilder(apiServer, String.format("/v4/layers/%s/%s/contents", dataType, id)); - } - - private String getUriComponentsBuilder(String host, String path) { - return UriComponentsBuilder.newInstance() - .scheme("https") - .host(host) - .path(path) - .build() - .toString(); - } - public String buildFieldsApiUri() { - // https://platform.climate.com/v4/fields - return getUriComponentsBuilder(apiServer, "/v4/fields"); - } -} \ No newline at end of file diff --git a/java/src/main/java/api/example/java/api/ClimateAPIs.java b/java/src/main/java/api/example/java/api/ClimateAPIs.java deleted file mode 100644 index 938bd11..0000000 --- a/java/src/main/java/api/example/java/api/ClimateAPIs.java +++ /dev/null @@ -1,100 +0,0 @@ -package api.example.java.api; - -import java.util.Iterator; -import java.util.List; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; - -import api.example.java.Config; -import api.example.java.model.ActivityResult; -import api.example.java.model.FieldResult; - -@Component -public class ClimateAPIs { - private static Logger logger = LoggerFactory.getLogger(ClimateAPIs.class); - @Autowired - protected RequestClient requestClient; - @Autowired - protected Config config; - - public Iterator getActivityIterator(String uri, String accessToken) { - return new Iterator() { - private boolean hasNext = true; - private String xLimitValue = "30"; - private HttpHeaders responseHeaders = new HttpHeaders(); - private final String xNextToken = "x-next-token"; - private final String xLimit = "x-limit"; - - @Override - public boolean hasNext() { - return hasNext; - } - - @Override - public ActivityResult next() { - return requestClient.getWebClient(uri, accessToken, config.apiKey) - .get() - .header(xLimit, xLimitValue) - .header(xNextToken, nextTokenValue()) - .exchange() - .doOnSuccess(clientResponse -> { - responseHeaders = clientResponse.headers().asHttpHeaders(); - logger.info("Headers -> {}", responseHeaders); - logger.info("Status code -> {}", clientResponse.statusCode()); - if (clientResponse.statusCode() == HttpStatus.PARTIAL_CONTENT - || clientResponse.statusCode() == HttpStatus.OK) { - hasNext = true; - } else { - hasNext = false; - } - }) - .flatMap(res -> res.bodyToMono(ActivityResult.class)) - .block(); - } - - private String nextTokenValue() { - List headers = responseHeaders.get(this.xNextToken); - return headers == null ? "" : headers.get(0); - } - }; - } - - public Iterator getContentIterator(String uri, Integer length, String accessToken) { - return new Iterator() { - private final int BUFFER_LEN = 1024 * 1024; - private int currentLength = 0; - - @Override - public boolean hasNext() { - return currentLength < length; - } - - @Override - public ByteArrayResource next() { - int start = currentLength; - currentLength += BUFFER_LEN; - return requestClient.getWebClient(uri, accessToken, config.apiKey) - .get() - .header(HttpHeaders.RANGE, String.format("bytes=%s-%s", start, currentLength - 1)) - .retrieve() - .bodyToMono(ByteArrayResource.class) - .block(); - } - }; - - } - - public FieldResult getFields(String uri, String accessToken) { - return requestClient.getWebClient(uri, accessToken, config.apiKey) - .get() - .retrieve() - .bodyToMono(FieldResult.class) - .block(); - } -} diff --git a/java/src/main/java/api/example/java/api/ClimateOAuth.java b/java/src/main/java/api/example/java/api/ClimateOAuth.java deleted file mode 100644 index 0b89f4b..0000000 --- a/java/src/main/java/api/example/java/api/ClimateOAuth.java +++ /dev/null @@ -1,59 +0,0 @@ -package api.example.java.api; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.reactive.function.BodyInserters; - -import api.example.java.Config; -import api.example.java.model.TokenResponse; - -@Component -public class ClimateOAuth { - - @Autowired - private Config config; - - @Autowired - private RequestClient requestClient; - - private static Logger logger = LoggerFactory.getLogger(ClimateOAuth.class); - - public TokenResponse getToken(String code, String redirectUri) { - - // grant_type=authorization_code&redirect_uri=${redirect_uri}&code=${code} - MultiValueMap formData = new LinkedMultiValueMap(); - formData.add("code", code); - formData.add("grant_type", "authorization_code"); - formData.add("redirect_uri", redirectUri); - - logger.info("Request body value map: {}", formData.toString()); - - return makeRequest(formData); - } - - private TokenResponse makeRequest(MultiValueMap formData) { - TokenResponse tokenResponse = requestClient.getWebClient(config.buildTokenUri(), config.getBase64Credentials()) - .post() - .body(BodyInserters.fromFormData(formData)) - .retrieve().bodyToMono(TokenResponse.class) - .block(); - return tokenResponse; - } - - public TokenResponse getRefreshToken(String refreshToken) { - - // grant_type=authorization_code&redirect_uri=${redirect_uri}&code=${code} - MultiValueMap formData = new LinkedMultiValueMap(); - formData.add("refresh_token", refreshToken); - formData.add("grant_type", "refresh_token"); - - logger.info("Request body value map: {}", formData.toString()); - - return makeRequest(formData); - } - -} diff --git a/java/src/main/java/api/example/java/api/RequestClient.java b/java/src/main/java/api/example/java/api/RequestClient.java deleted file mode 100644 index 4b533d9..0000000 --- a/java/src/main/java/api/example/java/api/RequestClient.java +++ /dev/null @@ -1,40 +0,0 @@ -package api.example.java.api; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.codec.LoggingCodecSupport; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.ExchangeStrategies; -import org.springframework.web.reactive.function.client.WebClient; - -@Component -public class RequestClient { - - public WebClient getWebClient(String uri, String accessToken, String apiKey) { - return WebClient.builder() - .baseUrl(uri).exchangeStrategies(exchangeStrategies()) - .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .defaultHeader(HttpHeaders.ACCEPT, MediaType.ALL_VALUE) - .defaultHeader("x-api-key", apiKey) - .build(); - } - - public WebClient getWebClient(String uri, String auth) { - return WebClient.builder() - .baseUrl(uri) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) - .defaultHeader(HttpHeaders.AUTHORIZATION, auth) - .exchangeStrategies(exchangeStrategies()) - .build(); - } - - private ExchangeStrategies exchangeStrategies() { - ExchangeStrategies exchangeStrategies = ExchangeStrategies.withDefaults(); - exchangeStrategies - .messageWriters() - .stream() - .filter(LoggingCodecSupport.class::isInstance) - .forEach(writer -> ((LoggingCodecSupport) writer).setEnableLoggingRequestDetails(true)); - return exchangeStrategies; - } -} diff --git a/java/src/main/java/api/example/java/controllers/AgronomicDataController.java b/java/src/main/java/api/example/java/controllers/AgronomicDataController.java deleted file mode 100644 index 35f6f5c..0000000 --- a/java/src/main/java/api/example/java/controllers/AgronomicDataController.java +++ /dev/null @@ -1,81 +0,0 @@ -package api.example.java.controllers; - -import java.io.IOException; -import java.text.NumberFormat; -import java.text.ParseException; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; - -import javax.servlet.ServletOutputStream; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - -import api.example.java.api.ClimateAPIs; -import api.example.java.model.Activity; -import api.example.java.model.ActivityResult; - -@Controller -public class AgronomicDataController extends BaseController { - private static final String ACTIVITIES_PAGE = "activities"; - private static final String ACTIVITIES = ACTIVITIES_PAGE; - private static final String DATA_TYPE = "dataType"; - private static final String ID = "id"; - private static final String DATA = "data"; - @Autowired - private ClimateAPIs climateAPIs; - private static Logger logger = LoggerFactory.getLogger(AgronomicDataController.class); - - @GetMapping("/agronomic") - public String agronomicData(Model model, @RequestParam(DATA) String dataType, HttpServletRequest request) { - logger.info("Listing agronomic {} data" + dataType); - List activities = new ArrayList(); - Iterator activityIterator = climateAPIs.getActivityIterator(agronomicApiUri(dataType), - getAccessTokenFromSession(request)); - while (activityIterator.hasNext()) { - ActivityResult activityResult = activityIterator.next(); - if (activityResult != null) { - List activityList = activityResult.getResults(); - if (activityList != null) { - activities.addAll(activityList); - } - } - } - model.addAttribute(ACTIVITIES, activities); - model.addAttribute(DATA_TYPE, dataType); - return ACTIVITIES_PAGE; - } - - @GetMapping("/agronomic-contents") - public void agronomicDataContent(Model model, @RequestParam(DATA) String dataType, @RequestParam(ID) String id, - @RequestParam("length") String length, HttpServletRequest request, HttpServletResponse response) - throws IOException, ParseException { - logger.info("Fetching contents of agronomic - {} - acitity id - {} length - {}", dataType, id, length); - Number number = NumberFormat.getNumberInstance(java.util.Locale.US).parse(length); - response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); - response.setStatus(HttpServletResponse.SC_OK); - response.addHeader(HttpHeaders.CONTENT_DISPOSITION, - String.format("attachment; filename=\"%s_%s.zip\"", id, dataType)); - ServletOutputStream out = response.getOutputStream(); - Iterator contentItr = climateAPIs.getContentIterator(agronomicContentsApiUri(id, dataType), - number.intValue(), getAccessTokenFromSession(request)); - while (contentItr.hasNext()) { - ByteArrayResource buffer = contentItr.next(); - out.write(buffer.getByteArray()); - } - out.flush(); - out.close(); - } - -} diff --git a/java/src/main/java/api/example/java/controllers/BaseController.java b/java/src/main/java/api/example/java/controllers/BaseController.java deleted file mode 100644 index 22dd3f2..0000000 --- a/java/src/main/java/api/example/java/controllers/BaseController.java +++ /dev/null @@ -1,62 +0,0 @@ -package api.example.java.controllers; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.reactive.function.client.WebClientResponseException; - -import api.example.java.Config; -import api.example.java.model.TokenResponse; - -public class BaseController { - protected static final String LOGIN_REDIRECT = "login-redirect"; - protected static final String HOME_PAGE = "home"; - protected static final String INDEX_PAGE = "index"; - protected static final String REDIRECT_TO_HOME_PAGE = "redirect:/"; - protected static final String TOKEN_RESPONSE = "tokenResponse"; - protected static final String CODE = "code"; - protected static final String REFRESH_TOKEN = "refresh_token"; - @Autowired - protected Config config; - - protected String getAccessTokenFromSession(HttpServletRequest request) { - String token = ""; - if (request.getSession().getAttribute(TOKEN_RESPONSE) instanceof TokenResponse) { - TokenResponse tokenResponse = (TokenResponse) request.getSession().getAttribute(TOKEN_RESPONSE); - token = tokenResponse.getAccessToken(); - } - return token; - } - - protected boolean isUserLoggedIn(HttpSession session) { - return session.getAttribute(TOKEN_RESPONSE) != null; - } - - @ExceptionHandler(WebClientResponseException.class) - public ResponseEntity handleWebClientResponseException(WebClientResponseException ex) { - return ResponseEntity.status(ex.getRawStatusCode()).body(ex.getResponseBodyAsString()); - } - - protected void saveTokenResponseInSession(HttpServletRequest request, TokenResponse tokenResponse) { - request.getSession().setAttribute(TOKEN_RESPONSE, tokenResponse); - } - - protected void cleanSession(HttpServletRequest request) { - request.getSession().removeAttribute(TOKEN_RESPONSE); - } - - protected String agronomicApiUri(String dataType) { - return config.buildAgronomicApiUri(dataType); - } - - protected String agronomicContentsApiUri(String id, String dataType) { - return config.buildAgronomicContentsApiUri(id, dataType); - } - - protected String fieldsApiUri() { - return config.buildFieldsApiUri(); - } -} diff --git a/java/src/main/java/api/example/java/controllers/FieldController.java b/java/src/main/java/api/example/java/controllers/FieldController.java deleted file mode 100644 index 0ad7cf7..0000000 --- a/java/src/main/java/api/example/java/controllers/FieldController.java +++ /dev/null @@ -1,29 +0,0 @@ -package api.example.java.controllers; - -import javax.servlet.http.HttpServletRequest; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; - -import api.example.java.api.ClimateAPIs; -@Controller -public class FieldController extends BaseController { - - private static final String FIELDS = "fields"; - - private static final String FIELDS_PAGE = FIELDS; - @Autowired - private ClimateAPIs climateAPIs; - private static Logger logger = LoggerFactory.getLogger(FieldController.class); - - @GetMapping("/fields") - public String getFields(Model model, HttpServletRequest request) { - logger.info("Fields controller entered"); - model.addAttribute(FIELDS, climateAPIs.getFields(fieldsApiUri(), getAccessTokenFromSession(request)).getResults()); - return FIELDS_PAGE; - } -} diff --git a/java/src/main/java/api/example/java/controllers/HomeController.java b/java/src/main/java/api/example/java/controllers/HomeController.java deleted file mode 100644 index cf2bc38..0000000 --- a/java/src/main/java/api/example/java/controllers/HomeController.java +++ /dev/null @@ -1,28 +0,0 @@ -package api.example.java.controllers; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpSession; - -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; - -@Controller -public class HomeController extends BaseController { - private static final String LOGIN_URI = "loginUri"; - - @GetMapping("/") - public String home(Model model, HttpSession session, HttpServletRequest request) { - if (isUserLoggedIn(session)) { - return HOME_PAGE; - } - model.addAttribute(LOGIN_URI, config.buildOauthLink( - ServletUriComponentsBuilder - .fromRequest(request) - .pathSegment(LOGIN_REDIRECT) - .build() - .toString())); - return INDEX_PAGE; - } -} diff --git a/java/src/main/java/api/example/java/controllers/LoginController.java b/java/src/main/java/api/example/java/controllers/LoginController.java deleted file mode 100644 index 362f2ab..0000000 --- a/java/src/main/java/api/example/java/controllers/LoginController.java +++ /dev/null @@ -1,45 +0,0 @@ -package api.example.java.controllers; - -import javax.servlet.http.HttpServletRequest; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - -import api.example.java.api.ClimateOAuth; -import api.example.java.model.TokenResponse; - -@Controller -public class LoginController extends BaseController { - - @Autowired - private ClimateOAuth oAuth; - - @GetMapping("/login-redirect") - public String loginRedirect(Model model, @RequestParam(CODE) String code, HttpServletRequest request) { - model.addAttribute(CODE, code); - TokenResponse tokenResponse = oAuth.getToken(code, request.getRequestURL().toString()); - saveTokenResponseInSession(request, tokenResponse); - model.addAttribute(TOKEN_RESPONSE, tokenResponse); - return HOME_PAGE; - } - - @GetMapping("/refresh-token") - public String refreshToken(Model model, @RequestParam(REFRESH_TOKEN) String refreshToken, - HttpServletRequest request) { - - TokenResponse tokenResponse = oAuth.getRefreshToken(refreshToken); - saveTokenResponseInSession(request, tokenResponse); - model.addAttribute(TOKEN_RESPONSE, tokenResponse); - return HOME_PAGE; - } - - @GetMapping("/logout") - public String logout(Model model, HttpServletRequest request) { - cleanSession(request); - return REDIRECT_TO_HOME_PAGE; - } - -} diff --git a/java/src/main/java/api/example/java/model/Activity.java b/java/src/main/java/api/example/java/model/Activity.java deleted file mode 100644 index b053b2f..0000000 --- a/java/src/main/java/api/example/java/model/Activity.java +++ /dev/null @@ -1,70 +0,0 @@ -package api.example.java.model; - -import java.util.List; - -public class Activity { - private String id; - private String startTime; - private String endTime; - private String createdAt; - private String updatedAt; - private int length; - private List fieldIds; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getStartTime() { - return startTime; - } - - public void setStartTime(String startTime) { - this.startTime = startTime; - } - - public String getEndTime() { - return endTime; - } - - public void setEndTime(String endTime) { - this.endTime = endTime; - } - - public String getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(String createdAt) { - this.createdAt = createdAt; - } - - public String getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(String updatedAt) { - this.updatedAt = updatedAt; - } - - public int getLength() { - return length; - } - - public void setLength(int length) { - this.length = length; - } - - public List getFieldIds() { - return fieldIds; - } - - public void setFieldIds(List fieldIds) { - this.fieldIds = fieldIds; - } - -} diff --git a/java/src/main/java/api/example/java/model/ActivityResult.java b/java/src/main/java/api/example/java/model/ActivityResult.java deleted file mode 100644 index c577cc6..0000000 --- a/java/src/main/java/api/example/java/model/ActivityResult.java +++ /dev/null @@ -1,17 +0,0 @@ -package api.example.java.model; - -import java.util.List; - -public class ActivityResult { - - private List results; - - public List getResults() { - return results; - } - - public void setResults(List results) { - this.results = results; - } - -} diff --git a/java/src/main/java/api/example/java/model/Field.java b/java/src/main/java/api/example/java/model/Field.java deleted file mode 100644 index 0321fb4..0000000 --- a/java/src/main/java/api/example/java/model/Field.java +++ /dev/null @@ -1,50 +0,0 @@ -package api.example.java.model; - -public class Field { - private String id; - private String name; - private String boundaryId; - private String resourceOwnerId; - private Parent parent; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getBoundaryId() { - return boundaryId; - } - - public void setBoundaryId(String boundaryId) { - this.boundaryId = boundaryId; - } - - public String getResourceOwnerId() { - return resourceOwnerId; - } - - public void setResourceOwnerId(String resourceOwnerId) { - this.resourceOwnerId = resourceOwnerId; - } - - public Parent getParent() { - return parent; - } - - public void setParent(Parent parent) { - this.parent = parent; - } - -} diff --git a/java/src/main/java/api/example/java/model/FieldResult.java b/java/src/main/java/api/example/java/model/FieldResult.java deleted file mode 100644 index 061daf9..0000000 --- a/java/src/main/java/api/example/java/model/FieldResult.java +++ /dev/null @@ -1,16 +0,0 @@ -package api.example.java.model; - -import java.util.List; - -public class FieldResult { - private List results; - - public List getResults() { - return results; - } - - public void setResults(List results) { - this.results = results; - } - -} diff --git a/java/src/main/java/api/example/java/model/Parent.java b/java/src/main/java/api/example/java/model/Parent.java deleted file mode 100644 index 4284595..0000000 --- a/java/src/main/java/api/example/java/model/Parent.java +++ /dev/null @@ -1,23 +0,0 @@ -package api.example.java.model; - -public class Parent { - private String id; - private String type; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - -} diff --git a/java/src/main/java/api/example/java/model/TokenResponse.java b/java/src/main/java/api/example/java/model/TokenResponse.java deleted file mode 100644 index 569975c..0000000 --- a/java/src/main/java/api/example/java/model/TokenResponse.java +++ /dev/null @@ -1,50 +0,0 @@ -package api.example.java.model; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public class TokenResponse { - - @JsonProperty("access_token") - private String accessToken; - - @JsonProperty("refresh_token") - private String responseToken; - - @JsonProperty("scope") - private String scopes; - - private User user; - - public User getUser() { - return user; - } - - public void setUser(User user) { - this.user = user; - } - - public String getAccessToken() { - return accessToken; - } - - public void setAccessToken(String accessToken) { - this.accessToken = accessToken; - } - - public String getRefreshToken() { - return responseToken; - } - - public void setResponseToken(String responseToken) { - this.responseToken = responseToken; - } - - public String getScopes() { - return scopes; - } - - public void setScopes(String scopes) { - this.scopes = scopes; - } - -} diff --git a/java/src/main/java/api/example/java/model/User.java b/java/src/main/java/api/example/java/model/User.java deleted file mode 100644 index c2245b4..0000000 --- a/java/src/main/java/api/example/java/model/User.java +++ /dev/null @@ -1,42 +0,0 @@ -package api.example.java.model; - -public class User { - - private String email; - private String firstName; - private String lastName; - private String country; - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public String getFirstname() { - return firstName; - } - - public void setFirstname(String firstName) { - this.firstName = firstName; - } - - public String getLastname() { - return lastName; - } - - public void setLastname(String lastName) { - this.lastName = lastName; - } - - public String getCountry() { - return country; - } - - public void setCountry(String country) { - this.country = country; - } - -} diff --git a/java/src/main/resources/application.properties b/java/src/main/resources/application.properties deleted file mode 100644 index b9d2799..0000000 --- a/java/src/main/resources/application.properties +++ /dev/null @@ -1,12 +0,0 @@ -spring.freemarker.template-loader-path: classpath:/templates -spring.freemarker.suffix: .ftl -spring.devtools.restart.additional-paths = src/main/resources/templates -spring.freemarker.cache = false -climate.login.server = climate.com -climate.login.path = /static/app-login/index.html -climate.token.server = api.climate.com -climate.token.path = /api/oauth/token -climate.api.server = platform.climate.com -logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE -spring.http.log-request-details=true -spring.output.ansi.enabled=ALWAYS \ No newline at end of file diff --git a/java/src/main/resources/static/favicon.png b/java/src/main/resources/static/favicon.png deleted file mode 100644 index bac278b38827c1729807107b82451e46b7afdd01..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7054 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 - -<@standardPage title="Api Java Example"> -

Partner API Demo Site

-

Welcome to the Climate Partner Demo App.

- - - - - - - - - - - <#list activities as activity> - - - - - - - - - - -
IdStart DateEnd Date Created AtUpdated AtLengthField Ids
- ${activity.id} - ${activity.startTime}${activity.endTime}${activity.createdAt}${activity.updatedAt}${activity.length}[ - <#list activity.fieldIds as field> - ${field}  - ] -
- \ No newline at end of file diff --git a/java/src/main/resources/templates/fields.ftl b/java/src/main/resources/templates/fields.ftl deleted file mode 100644 index 71161b1..0000000 --- a/java/src/main/resources/templates/fields.ftl +++ /dev/null @@ -1,28 +0,0 @@ -<#include "standardPage.ftl" /> - -<@standardPage title="Api Java Example"> -

Partner API Demo Site

-

Welcome to the Climate Partner Demo App.

- - - - - - - - - - <#list fields as field> - - - - - - - - - -
IdnameBoundaryIdResourceOwnerIdParentIdParentType
- ${field.id} - ${field.name}${field.boundaryId}${field.resourceOwnerId}${field.parent.id}${field.parent.type}
- \ No newline at end of file diff --git a/java/src/main/resources/templates/footer.ftl b/java/src/main/resources/templates/footer.ftl deleted file mode 100644 index a640126..0000000 --- a/java/src/main/resources/templates/footer.ftl +++ /dev/null @@ -1,28 +0,0 @@ -<#if logout_link?has_content> - Logout - -<#if tokenResponse?has_content> - Logout -  | - Home -  | - Refresh Token - <#if tokenResponse.scopes?has_content> - <#if tokenResponse.scopes?contains("fields:read")> -  | - Fields - - <#if tokenResponse.scopes?contains("asPlanted:read")> -  | - asPlanted - - <#if tokenResponse.scopes?contains("asApplied:read")> -  | - asApplied - - <#if tokenResponse.scopes?contains("asHarvested:read")> -  | - asHarvested - - - diff --git a/java/src/main/resources/templates/home.ftl b/java/src/main/resources/templates/home.ftl deleted file mode 100644 index 8127abd..0000000 --- a/java/src/main/resources/templates/home.ftl +++ /dev/null @@ -1,18 +0,0 @@ -<#include "standardPage.ftl" /> - -<@standardPage title="Api Java Example"> -

Partner API Demo Site

-

Welcome to the Climate Partner Demo App.

-

- Welcome - ${tokenResponse.user.firstname} ${tokenResponse.user.lastname} -

-

- Email - ${tokenResponse.user.email}
- Country - ${tokenResponse.user.country} -

-

- Access Token - ${tokenResponse.accessToken}
- Refresh Token - ${tokenResponse.refreshToken}
- Scopes - ${tokenResponse.scopes} -

- \ No newline at end of file diff --git a/java/src/main/resources/templates/index.ftl b/java/src/main/resources/templates/index.ftl deleted file mode 100644 index 3893dd2..0000000 --- a/java/src/main/resources/templates/index.ftl +++ /dev/null @@ -1,13 +0,0 @@ -<#include "standardPage.ftl" /> - -<@standardPage title="Api Java Example"> -

Partner API Demo Site

-

Welcome to the Climate Partner Demo App.

-

Imagine that this page is your great web application and you want - to connect it with Climate FieldView. To do this, you need to let your - users establish a secure connection between your app and FieldView. You - do this using Log In with FieldView.

-

- -

- diff --git a/java/src/main/resources/templates/standardPage.ftl b/java/src/main/resources/templates/standardPage.ftl deleted file mode 100644 index 645670b..0000000 --- a/java/src/main/resources/templates/standardPage.ftl +++ /dev/null @@ -1,29 +0,0 @@ -<#macro standardPage title=""> - - - - ${title} - - - - - - <#nested/> - <#include "footer.ftl"> - - - \ No newline at end of file diff --git a/java/src/test/java/api/example/java/AppTest.java b/java/src/test/java/api/example/java/AppTest.java deleted file mode 100644 index 607bc1a..0000000 --- a/java/src/test/java/api/example/java/AppTest.java +++ /dev/null @@ -1,11 +0,0 @@ - -package api.example.java; - -import org.junit.Test; - -public class AppTest { - @Test - public void testApp() { - // App classUnderTest = new App(); - } -} diff --git a/python/.gitignore b/python/.gitignore deleted file mode 100644 index b05546e..0000000 --- a/python/.gitignore +++ /dev/null @@ -1,92 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# IPython Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# dotenv -.env - -# virtualenv -venv/ -ENV/ - -# Spyder project settings -.spyderproject - -# Rope project settings -.ropeproject - -# venv -api-example/ diff --git a/python/LICENSE b/python/LICENSE deleted file mode 100644 index 8be6579..0000000 --- a/python/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2018 The Climate Corporation - - 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. diff --git a/python/README.md b/python/README.md deleted file mode 100644 index 5a8c08d..0000000 --- a/python/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# API Example - -Example app exercising some of Climate's [FieldView API](https://dev.fieldview.com). - -## Setup - -1. Install python 3.6+. - -```bash -# if you use Mac OS X and brew, this can be done with: -brew install python3 -``` - -2. Make a virtual environment, activate it, and install dependencies. - -```bash -python3 -m venv api-example -source api-example/bin/activate -pip install -r requirements.txt -``` - -3. Set the following environment variables (or hardcode them in `main.py`) -to the values provided to you by Climate: - -```bash -export CLIMATE_API_ID="my-api-id" -export CLIMATE_API_SECRET="azbq56fpadhnt8oukoeani2a4w" -export CLIMATE_API_KEY="my-api-id-216b9875-0158-4142-1ab2-7c3bdbd6a2157" -export CLIMATE_API_SCOPES="openid fields:read imagery:write" -``` - -Regarding scopes - see the [FieldView API technical documentation](https://dev.fieldview.com/technical-documentation/) for more scopes and their -corresponding endpoints (click the `Authorize` button in the swagger docs). - -4. When you're done running the example, deactivate the virtual environment with: - -```bash -deactivate -``` - -## Running the web example - -1. Follow steps in _Setup_ - -2. Start the server: - -```bash -python3 main.py -``` - -3. Open a browser to [localhost:8080/home](http://localhost:8080/home) - -## License - -Copyright © 2018 The Climate Corporation diff --git a/python/climate.py b/python/climate.py deleted file mode 100644 index 37065bd..0000000 --- a/python/climate.py +++ /dev/null @@ -1,568 +0,0 @@ -""" -Climate API demo code. This module shows how to: - -- Log in with Climate -- Refresh the access_token -- Fetch fields -- Fetch field boundaries -- Upload files - -License: -Copyright © 2018 The Climate Corporation -""" - -import requests - -import file -import os -from base64 import b64encode -from urllib.parse import urlencode -from curlify import to_curl -from logger import Logger - - -json_content_type = 'application/json' -binary_content_type = 'application/octet-stream' -metadata_content_types = ['application/vnd.climate.as-applied.zip'] - -base_login_uri = 'https://climate.com/static/app-login/index.html' -token_uri = 'https://api.climate.com/api/oauth/token' -api_uri = 'https://platform.climate.com' -CHUNK_SIZE = 5 * 1024 * 1024 - - -def login_uri(client_id, scopes, redirect_uri): - """ - Builds the URI for 'Log In with FieldView' link. - The redirect_uri is a uri on your system (this app) that will handle the - authorization once the user has authenticated with FieldView. - """ - params = { - 'scope': scopes, - 'page': 'oidcauthn', - 'response_type': 'code', - 'client_id': client_id, - 'redirect_uri': redirect_uri - } - return '{}?{}'.format(base_login_uri, urlencode(params)) - - -def authorization_header(client_id, client_secret): - """ - Builds the authorization header unique to your company or application. - :param client_id: Provided by Climate. - :param client_secret: Provided by Climate. - :return: Basic authorization header. - """ - pair = '{}:{}'.format(client_id, client_secret) - encoded = b64encode(pair.encode('ascii')).decode('ascii') - return 'Basic {}'.format(encoded) - - -def authorize(login_code, client_id, client_secret, redirect_uri): - """ - Exchanges the login code provided on the redirect request for an - access_token and refresh_token. Also gets user data. - :param login_code: Authorization code returned from Log In with FieldView - on redirect uri. - :param client_id: Provided by Climate. - :param client_secret: Provided by Climate. - :param redirect_uri: Uri to your redirect page. Needs to be the same as - the redirect uri provided in the initial Log In with FieldView request. - :return: Object containing user data, access_token and refresh_token. - """ - headers = { - 'authorization': authorization_header(client_id, client_secret), - 'content-type': 'application/x-www-form-urlencoded', - 'accept': 'application/json' - } - data = { - 'grant_type': 'authorization_code', - 'redirect_uri': redirect_uri, - 'code': login_code - } - res = requests.post(token_uri, headers=headers, data=urlencode(data)) - Logger().info(to_curl(res.request)) - if res.status_code == 200: - return res.json() - - Logger().error("Auth failed: %s" % res.status_code) - Logger().error("Auth failed: %s" % res.json()) - return None - - -def reauthorize(refresh_token, client_id, client_secret): - """ - Access_tokens expire after 4 hours. At any point before the end of that - period you may request a new access_token (and refresh_token) by submitting - a POST request to the /api/oauth/token end-point. Note that the data - submitted is slightly different than on initial authorization. Refresh - tokens are good for 30 days from their date of issue. Once this end-point - is called, the refresh token that is passed to this call is immediately set - to expired one hour from "now" and the newly issues refresh token will - expire 30 days from "now". Make sure to store the new refresh token so you - can use it in the future to get a new auth tokens as needed. If you lose - the refresh token there is no effective way to retrieve a new refresh token - without having the user log in again. - :param refresh_token: refresh_token supplied by initial - (or subsequent refresh) call. - :param client_id: Provided by Climate. - :param client_secret: Provided by Climate. - :return: Object containing user data, access_token and refresh_token. - """ - headers = { - 'authorization': authorization_header(client_id, client_secret), - 'content-type': 'application/x-www-form-urlencoded', - 'accept': 'application/json' - } - data = { - 'grant_type': 'refresh_token', - 'refresh_token': refresh_token - } - res = requests.post(token_uri, headers=headers, data=urlencode(data)) - Logger().info(to_curl(res.request)) - if res.status_code == 200: - return res.json() - - log_http_error(res) - return None - - -def bearer_token(token): - """ - Returns content of authorization header to be provided on all non-auth - API calls. - :param token: access_token returned from authorization call. - :return: Formatted header. - """ - return 'Bearer {}'.format(token) - - -def get_fields(token, api_key, next_token=None): - """ - Retrieve a user's field list from Climate. Note that fields - (like most data) is paginated to support very large - data sets. If the status code returned is 206 (partial content), then - there is more data to get. The x-next-token header provides a "marker" - that can be used on another request to get the next page of data. - Continue fetching data until the status is 200. Note that x-next-token - is based on date modified, so storing x-next-token can used as a method - to fetch updates over longer periods of time (though also note that this - will not result in fetching deleted objects since they no longer appear in - lists regardless of their modified date). - :param token: access_token - :param api_key: Provided by Climate. - :param next_token: Pagination token from previous request, or None. - :return: A (possibly empty) list of fields. - """ - uri = '{}/v4/fields'.format(api_uri) - headers = { - 'authorization': bearer_token(token), - 'accept': json_content_type, - 'x-api-key': api_key, - 'x-next-token': next_token - } - - res = requests.get(uri, headers=headers) - Logger().info(to_curl(res.request)) - - if res.status_code == 200: - return res.json()['results'] - if res.status_code == 206: - next_token = res.headers['x-next-token'] - return res.json()['results'] + get_fields(token, api_key, next_token) - - log_http_error(res) - return [] - - -def get_boundary(boundary_id, token, api_key): - """ - Retrieve field boundary from Climate. Note that boundary objects are - immutable, so whenever a field's boundary is updated the boundaryId - property of the field will change and you will need to fetch the - updated boundary. - :param boundary_id: UUID of field boundary to retrieve. - :param token: access_token - :param api_key: Provided by Climate - :return: geojson object representing the boundary of the field. - """ - uri = '{}/v4/boundaries/{}'.format(api_uri, boundary_id) - headers = { - 'authorization': bearer_token(token), - 'accept': json_content_type, - 'x-api-key': api_key - } - - res = requests.get(uri, headers=headers) - Logger().info(to_curl(res.request)) - - if res.status_code == 200: - return res.json() - - log_http_error(res) - return None - - -def upload(f, content_type, token, api_key): - """Upload a file with the given content type to Climate - - This example supports files up to 5 MiB (5,242,880 bytes). - - Returns The upload id if the upload is successful, False otherwise. - """ - uri = '{}/v4/uploads'.format(api_uri) - headers = { - 'authorization': bearer_token(token), - 'x-api-key': api_key - } - md5 = file.md5(f) - length = file.length(f) - data = { - 'md5': md5, - 'length': length, - 'contentType': content_type - } - - if any(content_type in ct for ct in metadata_content_types): - data['metadata'] = { - 'fileName' : f.filename - } - - # initiate upload - res = requests.post(uri, headers=headers, json=data) - Logger().info(to_curl(res.request)) - - if res.status_code == 201: - upload_id = res.json() - Logger().info("Upload Id: %s" % upload_id) - put_uri = '{}/{}'.format(uri, upload_id) - - # for this example, size is assumed to be small enough for a - # single upload (less than or equal to 5 MiB) - headers['content-range'] = 'bytes {}-{}/{}'.format(0, - (length - 1), - length) - headers['content-type'] = binary_content_type - - f.seek(0) - - # send image - for position in range(0, length, CHUNK_SIZE): - buf = f.read(CHUNK_SIZE) - headers['content-range'] = 'bytes {}-{}/{}'.format( - position, position + len(buf) - 1, length) - try: - res = requests.put(put_uri, headers=headers, data=buf) - Logger().info(headers) - except Exception as e: - Logger().error("Exception: %s" % e) - - if res.status_code == 204: - return upload_id - - return False - - -def get_upload_status(upload_id, token, api_key): - """ - Retrieve the status of an upload. See - https://dev.fieldview.com/technical-documentation/ for possible status - values and their meaning. - :param upload_id: id of upload - :param token: access_token - :param api_key: Provided by Climate - :return: status json object containing upload id and status. - """ - uri = '{}/v4/uploads/{}/status'.format(api_uri, upload_id) - headers = { - 'authorization': bearer_token(token), - 'accept': json_content_type, - 'x-api-key': api_key - } - - res = requests.get(uri, headers=headers) - Logger().info(to_curl(res.request)) - - if res.status_code == 200: - return res.json() - - log_http_error(res) - return None - - -def get_scouting_observations(token, - api_key, - limit=100, - next_token=None, - occurred_after=None, - occurred_before=None): - """ - Retrieve a list of scouting observations created or updated by the user - identified by the Authorization header. - https://dev.fieldview.com/technical-documentation/ for possible status - values and their meaning. - :param token: access_token - :param api_key: Provided by Climate - :param next-token: Opaque string which allows for fetching the next batch - of results. - :param limit: Max number of results to return per batch. Must be between - 1 and 100 inclusive. - :param occurred_after: Optional start time by which to filter layer - results. - :param occurred_before: Optional end time by which to filter layer results. - :return: status json object containing scouting observation list - and status. - """ - uri = '{}/v4/layers/scoutingObservations'.format(api_uri) - headers = { - 'authorization': bearer_token(token), - 'accept': json_content_type, - 'x-api-key': api_key, - 'x-limit': str(limit), - 'x-next-token': next_token - } - params = { - 'occurredAfter': occurred_after, - 'occurredBefore': occurred_before - } - - res = requests.get(uri, headers=headers, params=params) - Logger().info(to_curl(res.request)) - - if res.status_code == 200: - return res.json()['results'] - if res.status_code == 206: - next_token = res.headers['x-next-token'] - return res.json()['results'] + \ - get_scouting_observations(token, - api_key, - limit, - next_token, - occurred_after, - occurred_before) - log_http_error(res) - return [] - - -def get_scouting_observation(token, api_key, scouting_observation_id): - """ - Retrieve an individual scouting observation by id. Ids are retrieved via - the /layers/scoutingObservations route. - https://dev.fieldview.com/technical-documentation/ for possible status - values and their meaning. - :param token: access_token - :param api_key: Provided by Climate - :param scouting_observation_id: Unique identifier of the - Scouting Observation. - - """ - uri = '{}/v4/layers/scoutingObservations/{}'.format( - api_uri, scouting_observation_id) - headers = { - 'authorization': bearer_token(token), - 'accept': json_content_type, - 'x-api-key': api_key - } - - res = requests.get(uri, headers=headers) - Logger().info(to_curl(res.request)) - - if res.status_code == 200: - return res.json() - - log_http_error(res) - return None - - -def get_scouting_observation_attachments(token, - api_key, - scouting_observation_id): - """ - Retrieve attachments associated with a given scouting observation. Photos - added to scouting notes in the FieldView app are capped to 20MB, and we - won’t store photos larger than that in a scouting note. - https://dev.fieldview.com/technical-documentation/ for possible status - values and their meaning. - :param token: access_token - :param api_key: Provided by Climate - :param scouting_observation_id: Unique identifier of the - Scouting Observation. - - """ - uri = '{}/v4/layers/scoutingObservations/{}/attachments'.format( - api_uri, scouting_observation_id) - headers = { - 'authorization': bearer_token(token), - 'accept': json_content_type, - 'x-api-key': api_key - } - - res = requests.get(uri, headers=headers) - Logger().info(to_curl(res.request)) - - if res.status_code == 200: - return res.json()['results'] - - log_http_error(res) - return [] - - -def log_http_error(response): - - """ - Private function to log errors on server console - :param response: http response object. - """ - if response.status_code == 403: - Logger().error("Permission error, current scopes are - {}".format( - os.environ['CLIMATE_API_SCOPES'])) - elif response.status_code == 400: - Logger().error("Bad request - {}".format(response.text)) - elif response.status_code == 401: - Logger().error("Unauthorized - {}".format(response.text)) - elif response.status_code == 404: - Logger().error("Resource not found - {}".format(response.text)) - elif response.status_code == 416: - Logger().error("Range Not Satisfiable - {}".format(response.text)) - elif response.status_code == 500: - Logger().error("Internal server error - {}".format(response.text)) - elif response.status_code == 503: - Logger().error("Server busy - {}".format(response.text)) - - -def get_scouting_observation_attachments_contents(token, - api_key, - scouting_observation_id, - attachment_id, - content_type, - length): - """ - Retrieve the binary contents of a scouting observation’s attachment. - https://dev.fieldview.com/technical-documentation/ for possible status - values and their meaning. - :param token: access_token - :param api_key: Provided by Climate - :param scouting_observation_id: Unique identifier of the Scouting - Observation. - :param attachment_id : Unique identifiler of the attachment - - """ - - uri = '{}/v4/layers/scoutingObservations/{}/attachments/{}/contents'.\ - format(api_uri, - scouting_observation_id, - attachment_id) - - headers = { - 'authorization': bearer_token(token), - 'accept': content_type, - 'x-api-key': api_key, - } - - return fetch_contents(uri, headers, length) - - -def get_as_planted(token, api_key, next_token): - """ - Retrieve as Planted activities - :param token: access_token - :param api_key: Provided by Climate - :param next_token: Opaque string which allows for fetching the next batch - of results. - """ - return get_activities(token, api_key, next_token, "asPlanted") - - -def get_as_harvested(token, api_key, next_token): - """ - Retrieve as Harvested activities - :param token: access_token - :param api_key: Provided by Climate - :param next_token: Opaque string which allows for fetching the next batch - of results. - """ - - return get_activities(token, api_key, next_token, "asHarvested") - - -def get_as_applied(token, api_key, next_token): - """ - Retrieve as Applied activities - :param token: access_token - :param api_key: Provided by Climate - :param next_token: Opaque string which allows for fetching the next batch - of results. - """ - - return get_activities(token, api_key, next_token, "asApplied") - - -def get_activities(token, api_key, next_token, activity): - """ - Retrieve a list of field activities. - https://dev.fieldview.com/technical-documentation/ for possible status - values and their meaning. - :param token: access_token - :param api_key: Provided by Climate - :param next-token: Opaque string which allows for fetching the next batch - of results. - :param activity: name of activity - - """ - uri = '{}/v4/layers/{}'.format(api_uri, activity) - - headers = { - 'authorization': bearer_token(token), - 'x-api-key': api_key, - 'x-next-token': next_token, - 'x-limit': str(10) - } - - res = requests.get(uri, headers=headers) - - if res.status_code == 200: - return None, res.json()['results'] - if res.status_code == 206: - return res.headers['x-next-token'], res.json()['results'] - if res.status_code == 304: - return None, None - - log_http_error(res) - - return None, None - - -def get_activity_contents(token, api_key, layer_id, activity_id, length): - """ - Retrieve a content of field activity. - https://dev.fieldview.com/technical-documentation/ for possible status - values and their meaning. - :param token: access_token - :param api_key: Provided by Climate - :param layer_id: name of activity - :param activity_id: id of activity - :param length: content length - - """ - uri = '{}/v4/layers/{}/{}/contents'.format(api_uri, layer_id, activity_id) - - headers = { - 'authorization': bearer_token(token), - 'x-api-key': api_key, - } - - return fetch_contents(uri, headers, length) - - -def fetch_contents(uri, headers, length): - chunk_size = 1 * 1024 * 1024 - for start in range(0, length, chunk_size): - end = min(length, start + chunk_size) - headers['Range'] = 'bytes={}-{}'.format(start, end - 1) - res = requests.get(uri, headers=headers) - if res.status_code == 200 or res.status_code == 206: - yield res.content - else: - log_http_error(res) - break diff --git a/python/file.py b/python/file.py deleted file mode 100644 index 0655341..0000000 --- a/python/file.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -File utilities - -License: -Copyright © 2018 The Climate Corporation -""" - -import hashlib -import os - - -def length(f): - """Get the length of a file""" - f.seek(0, os.SEEK_END) - length = f.tell() - return length - - -def md5(f): - """Get the md5 of a file's contents""" - f.seek(0) - md5 = hashlib.md5() - chunk_size = 2**10 - - for bytes_chunk in iter(lambda: f.read(chunk_size), b''): - md5.update(bytes_chunk) - - return md5.hexdigest() diff --git a/python/logger.py b/python/logger.py deleted file mode 100644 index 2159ebe..0000000 --- a/python/logger.py +++ /dev/null @@ -1,28 +0,0 @@ -import logging -import sys - - -class Logger: - """ - Simple singleton class to encapsulate logging to the Flask app object so - we can have unified logging. - """ - instance = None - - def __new__(cls, logger=None): - if not Logger.instance: - if not logger: - raise ValueError("No logger specified on creation of Logger\ - singleton.") - Logger.instance = logger - logger.setLevel(logging.INFO) - handler = logging.StreamHandler(sys.stdout) - handler.setLevel(logging.INFO) - formatter = logging.Formatter('%(levelname)s - %(message)s') - handler.setFormatter(formatter) - logger.addHandler(handler) - return Logger.instance - return Logger.instance - - def __getattr__(self, name): - return getattr(self.instance, name) diff --git a/python/main.py b/python/main.py deleted file mode 100644 index 9365482..0000000 --- a/python/main.py +++ /dev/null @@ -1,574 +0,0 @@ -""" -Partner example app - -Start a simple app server: - - allow user login via Climate - - display user's Climate fields - - retrieve field boundary info - - basic file upload to Climate - -We use Flask in this example to provide a simple HTTP server. You will notice -that some of the functions in this file are decorated with @app.route() which -registers them with Flask as functions to service requests to the specified -URIs. - -This file (main.py) provides the web UI and framework for the demo app. All -the work with the Climate API happens in climate.py. - -Note: For this example, only one "user" can be logged into the example app at -a time. - -License: -Copyright © 2018 The Climate Corporation -""" - -import json -import os -from logger import Logger - -from flask import Flask, request, redirect, url_for, send_from_directory -from flask import Response, stream_with_context -import climate - -# Configuration of your Climate partner credentials. This assumes you have -# placed them in your environment. You may -# also choose to just hard code them here if you prefer. - -CLIMATE_API_ID = os.environ['CLIMATE_API_ID'] # OAuth2 client ID -CLIMATE_API_SECRET = os.environ['CLIMATE_API_SECRET'] # OAuth2 client secret -CLIMATE_API_SCOPES = os.environ['CLIMATE_API_SCOPES'] # Oauth2 scope list -CLIMATE_API_KEY = os.environ['CLIMATE_API_KEY'] # X-Api-Key header -# Partner app server - -app = Flask(__name__) -logger = Logger(app.logger) - -# User state - only one user at a time. In your application this would be -# handled by your session management and backing -# storage. - -_state = {} - - -def set_state(**kwargs): - global _state - if 'access_token' in kwargs: - _state['access_token'] = kwargs['access_token'] - if 'refresh_token' in kwargs: - _state['refresh_token'] = kwargs['refresh_token'] - if 'user' in kwargs: - _state['user'] = kwargs['user'] - if 'fields' in kwargs: - _state['fields'] = kwargs['fields'] - - -def clear_state(): - set_state(access_token=None, refresh_token=None, user=None, fields=None) - - -def state(key): - global _state - return _state.get(key) - - -# Routes - - -@app.route('/home') -def home(): - if state('user'): - return user_homepage() - return no_user_homepage() - - -def no_user_homepage(): - """ - This is logically the first place a user will come. On your site it will - be some page where you present them with a link to Log In with FieldView. - The main thing here is that you provide a correctly formulated link with - the required parameters and correct button image. - :return: None - """ - url = climate.login_uri(CLIMATE_API_ID, CLIMATE_API_SCOPES, redirect_uri()) - return """ -

Partner API Demo Site

-

Welcome to the Climate Partner Demo App.

-

Imagine that this page is your great web application and you - want to connect it with Climate FieldView. To do this, you need - to let your users establish a secure connection between your app - and FieldView. You do this using Log In with FieldView.

-

-

""".format(url) - - -def user_homepage(): - """ - This page just demonstrates some basic Climate FieldView API operations - such as getting field details, accessing user information and and - refreshing the authorization token. - :return: None - """ - field_list = render_ul(render_field_link(f) for f in state('fields')) - return """ -

Partner API Demo Site

-

User name retrieved from FieldView: {first} {last}

-

Access Token: {access_token}

-

Refresh Token: {refresh_token} - (Refresh)

-
-

Your Climate fields:{fields}

-

Upload data

-

Scouting Observations

-
-

Your fields activities:

-

asPlanted

-

asHarvested

-

asApplied

-
-

Log out

- """.format(first=state('user')['firstname'], - last=state('user')['lastname'], - access_token=state('access_token'), - refresh_token=state('refresh_token'), - fields=field_list, - upload=url_for('upload_form'), - logout=url_for('logout_redirect'), - refresh=url_for('refresh_token'), - scouting_observations=url_for('scouting_observations'), - as_planted=url_for('as_planted'), - as_harvested=url_for('as_harvested'), - as_applied=url_for('as_applied')) - - -@app.route('/login-redirect') -def login_redirect(): - """ - This is the page a user will come back to after having successfully logged - in with FieldView. The URI was provided as one of the parameters to the - login URI above. The "code" parameter in the URI's query string contains - the access_token and refresh_token. - :return: - """ - code = request.args['code'] - if code: - resp = climate.authorize(code, - CLIMATE_API_ID, - CLIMATE_API_SECRET, - redirect_uri()) - if resp: - # Store tokens and user in state for subsequent requests. - access_token = resp['access_token'] - refresh_token = resp['refresh_token'] - set_state(user=resp['user'], - access_token=access_token, - refresh_token=refresh_token) - - # Fetch fields and store in state just for example purposes. You - # might well do this at the time of need, - # or not at all depending on your app. - fields = climate.get_fields(access_token, CLIMATE_API_KEY) - set_state(fields=fields) - - return redirect(url_for('home')) - - -@app.route('/refresh-token') -def refresh_token(): - """ - This route doesn't have any page associated with it; it just refreshes the - authorization token and redirects back to the home page. As a by-product, - this also refreshes the user data. - :return: - """ - resp = climate.reauthorize(state('refresh_token'), - CLIMATE_API_ID, - CLIMATE_API_SECRET) - if resp: - # Store tokens and user in state for subsequent requests. - access_token = resp['access_token'] - refresh_token = resp['refresh_token'] - set_state(user=resp['user'], access_token=access_token, - refresh_token=refresh_token) - - return redirect(url_for('home')) - - -@app.route('/logout-redirect') -def logout_redirect(): - """ - Clears all current user data. Does not make any Climate API calls. - :return: - """ - clear_state() - return redirect(url_for('home')) - - -@app.route('/field/') -def field(field_id): - """ - Shows how to fetch field boundary information and displays it as raw - geojson data. - :param field_id: - :return: - """ - field = [f for f in state('fields') if f['id'] == field_id][0] - - boundary = climate.get_boundary(field['boundaryId'], - state('access_token'), - CLIMATE_API_KEY) - - return """ -

Partner API Demo Site

-

Field Name: {name}

-

Boundary info:

{boundary}

-

Return home

- """.format(name=field['name'], - boundary=json.dumps(boundary, indent=4, sort_keys=True), - home=url_for('home')) - - -@app.route('/upload', methods=['GET', 'POST']) -def upload_form(): - """ - Initially (when method=GET) render the upload form to collect information - about the file to upload.When the form is POSTed, invoke the actual Climate - API code to do the chunked upload. - :return: - """ - if request.method == 'POST': - if 'file' not in request.files or request.files['file'].stream is None: - return redirect(url_for('upload_form')) - - f = request.files['file'] - content_type = request.form['file_content_type'] - upload_id = climate.upload( - f, content_type, state('access_token'), CLIMATE_API_KEY) - - return """ -

Partner API Demo Site

-

Upload data

-

File uploaded: {upload_id} - Get Status

-

Return home

- """.format(upload_id=upload_id, - status_url=url_for( - 'update_status', upload_id=upload_id), - home=url_for('home')) - - return """ -

Partner API Demo Site

-

Upload data

-
-

Content type:

-

-

-
-

Return home

- """.format(home=url_for('home')) - - -@app.route('/upload/', methods=['GET']) -def update_status(upload_id): - """ - Shows the status of an upload. Uploads are processed asynchronously so to - know if an upload was successful you need to check its status until it is - either in the INBOX or SUCCESS state (it worked) or the INVALID state - (it failed). This method demonstrates the API call to get the status for - a single upload id. There is also a call to get stattus for a list - of upload ids. - :param upload_id: uuid of upload returned by API. - :return: - """ - status = climate.get_upload_status(upload_id, - state('access_token'), - CLIMATE_API_KEY) - - return """ -

Partner API Demo Site

-

Upload ID: {upload_id}

-

Status: {status} - Refresh

-

Return home

- """.format(upload_id=upload_id, - status=status.get('status'), - home=url_for('home')) - - -# Various utilities just to make the demo app work. No Climate API stuff here. - - -@app.route('/res/') -def send_res(path): - """ - Sends a static resource. - """ - return send_from_directory('res', path) - - -def render_ul(xs): - return '
    {}
'.format('\n'.join('
  • {}
  • '.format(x) for x in xs)) - - -def render_field_link(field): - field_id = field['id'] - return '{name} ({id})'.format( - link=url_for('field', field_id=field_id), - name=field['name'], - id=field_id) - - -def render_scouting_observation_link(scouting_observation): - oid = scouting_observation['id'] - return '{oid}'.format( - link=url_for('scouting_observation', scouting_observation_id=oid), - oid=oid) - - -def render_attachment_link(scouting_observation_id, attachment): - attachment_id = attachment['id'] - if attachment['status'] == 'DELETED': - link = '' - else: - link = ': Get contents'.format( - link=url_for('scouting_observation_attachments_contents', - scouting_observation_id=scouting_observation_id, - attachment_id=attachment_id, - contentType=attachment['contentType'], - length=attachment['length'])) - - return """

    {attachment_id}{link}

    -

    {info}

    - """.format(link=link, - attachment_id=attachment_id, - info=json.dumps(attachment, indent=4, sort_keys=True)) - - -def render_activitiy_link(activity, link): - activity_id = activity['id'] - link = '{link}/{activity_id}/contents?length={length}'.format( - link=link, - activity_id=activity_id, - length=activity['length']) - - return """ - {activity_id} : Get contents -

    {body}

    - """.format(activity_id=activity_id, - link=link, - body=json.dumps(activity, indent=4, sort_keys=True)) - - -def redirect_uri(): - """ - :return: Returns uri for redirection after Log In with FieldView. - """ - return url_for('login_redirect', _external=True) - - -@app.route('/scouting-observation/', methods=['GET']) -def scouting_observation(scouting_observation_id): - """ - Shows the details of a scouting observation - :param scouting_observation_id: a scouting observation identifier - - :return: returns the html response - """ - observation = climate.get_scouting_observation(state('access_token'), - CLIMATE_API_KEY, - scouting_observation_id) - return """ -

    Partner API Demo Site

    -

    Scouting Observation ID: {scouting_observation_id}

    -

    {json}

    -

    List attachments

    -

    Return to Observations list

    -

    Return home

    - """.format(scouting_observation_id=scouting_observation_id, - json=json.dumps(observation, indent=4, sort_keys=True), - observations=url_for('scouting_observations'), - attachments=url_for( - 'scouting_observation_attachments', - scouting_observation_id=scouting_observation_id), - home=url_for('home')) - - -@app.route('/scouting-observations', methods=['GET']) -def scouting_observations(): - """ - Displays the list of scouting observations - - :return: returns the html response which shows list of observations - """ - observations = climate.get_scouting_observations(state('access_token'), - CLIMATE_API_KEY, - 100) - body = "

    No Scouting Observations found!

    " - if observations: - scouting_observations = render_ul( - render_scouting_observation_link(o) for o in observations) - body = "

    Your Climate Scouting Observations:\ - {scouting_observations}

    ".format( - scouting_observations=scouting_observations) - - return """ -

    Partner API Demo Site

    - {body} -

    Return home

    - """.format(body=body, home=url_for('home')) - - -@app.route('/scouting-observation//attachments', - methods=['GET']) -def scouting_observation_attachments(scouting_observation_id): - """ - Shows the list of attachments for a given scouting observation - :param scouting_observation_id: a scouting observation identifier - :return: returns html which shows list of attachments. - """ - ats = climate.get_scouting_observation_attachments(state('access_token'), - CLIMATE_API_KEY, - scouting_observation_id) - - body = "

    No attachments found!

    " - if ats: - attachments = render_ul( - render_attachment_link(scouting_observation_id, a) for a in ats) - body = "

    Your Climate Scouting Observations attachments:\ - {attachments}

    ".format( - attachments=attachments) - - return """ -

    Partner API Demo Site

    - {body} -

    Return to Observation:{soid}

    -

    Return home

    - """.format(body=body, - home=url_for('home'), - attachments=url_for( - 'scouting_observation', - scouting_observation_id=scouting_observation_id), - soid=scouting_observation_id) - - -@app.route( - '/scouting-observation/' - '/attachments/', - methods=['GET']) -def scouting_observation_attachments_contents(scouting_observation_id, - attachment_id): - """ - Downloads the attachment contents - :param scouting_observation_id: a scouting observation identifier - :param attachment_id: an attachment identifier - :return: returns contents of attachment. - """ - content_type = request.args.get('contentType') - length = int(request.args.get('length')) - # stream the content back to client - headers = { - 'Content-type': 'image/jpeg' - } - content = climate.get_scouting_observation_attachments_contents( - state('access_token'), - CLIMATE_API_KEY, - scouting_observation_id, - attachment_id, - content_type, - length - ) - return Response(response=content, headers=headers) - - -def get_callee(activity): - method = 'get_{}'.format(activity) - return getattr(climate, method) - - -def handle_activity(activity): - - next_token = request.args.get('next_token') - has_more_records, activities = get_callee(activity)( - state('access_token'), - CLIMATE_API_KEY, - next_token) - - body = "

    No data found!

    " - - if activities is not None: - activities_list = render_ul(render_activitiy_link( - a, url_for(activity)) for a in activities) - body = "

    Your Climate {activity} activities:{activities_list}

    "\ - .format(activities_list=activities_list, activity=activity) - - more_records_html = "" - if has_more_records is not None: - next_link = url_for(activity, next_token=has_more_records) - more_records_html = "

    More records >>\ -

    ".format(next_link=next_link) - return """ -

    Partner API Demo Site

    - {body} - {more_records_html} -

    Return home

    - """.format(body=body, - home=url_for('home'), - more_records_html=more_records_html) - - -@app.route('/layers/asPlanted', methods=['GET']) -def as_planted(): - """ - Shows list of planting activities - :return: returns planting activities. - """ - return handle_activity("as_planted") - - -@app.route('/layers/asHarvested', methods=['GET']) -def as_harvested(): - """ - Shows list of harvesting activities - :return: returns harvesting activities. - """ - return handle_activity("as_harvested") - - -@app.route('/layers/asApplied', methods=['GET']) -def as_applied(): - """ - Shows list of application activities - :return: returns application activities. - """ - return handle_activity("as_applied") - - -@app.route('/layers///contents') -def get_activity_contents(layer_id, activity_id): - """ - Download the contents of given activity - :param layer_id: name of activity - :param activity_id: id of activity - :return: returns contents of given activity. - """ - length = int(request.args.get('length')) - content = climate.get_activity_contents( - state('access_token'), - CLIMATE_API_KEY, - layer_id, - activity_id, - length) - response = Response(stream_with_context(content), - mimetype='application/zip') - response.headers['Content-Disposition'] = 'attachment; filename=data.zip' - return response - - -# start app - - -if __name__ == '__main__': - clear_state() - app.run( - host="localhost", - port=8080 - ) diff --git a/python/requirements.txt b/python/requirements.txt deleted file mode 100644 index 602b85f..0000000 --- a/python/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -click==6.7 -Flask==1.1.1 -itsdangerous==0.24 -Jinja2==2.11.3 -MarkupSafe==1.0 -requests==2.20.0 -Werkzeug==0.15.5 -curlify==1.2.1 diff --git a/python/res/fv-login-button.png b/python/res/fv-login-button.png deleted file mode 100644 index d20b7f882d2e181c1806bd65a76d7e50bf499c85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2314 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 Date: Thu, 17 Mar 2022 12:12:06 -0700 Subject: [PATCH 2/4] add back v4 demo apps --- v4/.gitignore | 11 + v4/java/.gitignore | 11 + v4/java/LICENSE | 201 ++++++ v4/java/README.md | 31 + v4/java/build.gradle | 52 ++ v4/java/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 55190 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + v4/java/gradlew | 172 ++++++ v4/java/gradlew.bat | 84 +++ v4/java/settings.gradle | 10 + .../src/main/java/api/example/java/App.java | 11 + .../main/java/api/example/java/Config.java | 104 ++++ .../api/example/java/api/ClimateAPIs.java | 100 +++ .../api/example/java/api/ClimateOAuth.java | 59 ++ .../api/example/java/api/RequestClient.java | 40 ++ .../controllers/AgronomicDataController.java | 81 +++ .../java/controllers/BaseController.java | 62 ++ .../java/controllers/FieldController.java | 29 + .../java/controllers/HomeController.java | 28 + .../java/controllers/LoginController.java | 45 ++ .../java/api/example/java/model/Activity.java | 70 +++ .../example/java/model/ActivityResult.java | 17 + .../java/api/example/java/model/Field.java | 50 ++ .../api/example/java/model/FieldResult.java | 16 + .../java/api/example/java/model/Parent.java | 23 + .../api/example/java/model/TokenResponse.java | 50 ++ .../java/api/example/java/model/User.java | 42 ++ .../src/main/resources/application.properties | 12 + v4/java/src/main/resources/static/favicon.png | Bin 0 -> 7054 bytes .../main/resources/static/fv-login-button.png | Bin 0 -> 2314 bytes .../main/resources/templates/activities.ftl | 34 ++ .../src/main/resources/templates/fields.ftl | 28 + .../src/main/resources/templates/footer.ftl | 28 + v4/java/src/main/resources/templates/home.ftl | 18 + .../src/main/resources/templates/index.ftl | 13 + .../main/resources/templates/standardPage.ftl | 29 + .../test/java/api/example/java/AppTest.java | 11 + v4/python/.gitignore | 92 +++ v4/python/LICENSE | 201 ++++++ v4/python/README.md | 55 ++ v4/python/climate.py | 568 +++++++++++++++++ v4/python/file.py | 28 + v4/python/logger.py | 28 + v4/python/main.py | 574 ++++++++++++++++++ v4/python/requirements.txt | 8 + v4/python/res/fv-login-button.png | Bin 0 -> 2314 bytes 46 files changed, 3131 insertions(+) create mode 100644 v4/.gitignore create mode 100644 v4/java/.gitignore create mode 100644 v4/java/LICENSE create mode 100644 v4/java/README.md create mode 100644 v4/java/build.gradle create mode 100644 v4/java/gradle/wrapper/gradle-wrapper.jar create mode 100644 v4/java/gradle/wrapper/gradle-wrapper.properties create mode 100755 v4/java/gradlew create mode 100644 v4/java/gradlew.bat create mode 100644 v4/java/settings.gradle create mode 100644 v4/java/src/main/java/api/example/java/App.java create mode 100644 v4/java/src/main/java/api/example/java/Config.java create mode 100644 v4/java/src/main/java/api/example/java/api/ClimateAPIs.java create mode 100644 v4/java/src/main/java/api/example/java/api/ClimateOAuth.java create mode 100644 v4/java/src/main/java/api/example/java/api/RequestClient.java create mode 100644 v4/java/src/main/java/api/example/java/controllers/AgronomicDataController.java create mode 100644 v4/java/src/main/java/api/example/java/controllers/BaseController.java create mode 100644 v4/java/src/main/java/api/example/java/controllers/FieldController.java create mode 100644 v4/java/src/main/java/api/example/java/controllers/HomeController.java create mode 100644 v4/java/src/main/java/api/example/java/controllers/LoginController.java create mode 100644 v4/java/src/main/java/api/example/java/model/Activity.java create mode 100644 v4/java/src/main/java/api/example/java/model/ActivityResult.java create mode 100644 v4/java/src/main/java/api/example/java/model/Field.java create mode 100644 v4/java/src/main/java/api/example/java/model/FieldResult.java create mode 100644 v4/java/src/main/java/api/example/java/model/Parent.java create mode 100644 v4/java/src/main/java/api/example/java/model/TokenResponse.java create mode 100644 v4/java/src/main/java/api/example/java/model/User.java create mode 100644 v4/java/src/main/resources/application.properties create mode 100644 v4/java/src/main/resources/static/favicon.png create mode 100644 v4/java/src/main/resources/static/fv-login-button.png create mode 100644 v4/java/src/main/resources/templates/activities.ftl create mode 100644 v4/java/src/main/resources/templates/fields.ftl create mode 100644 v4/java/src/main/resources/templates/footer.ftl create mode 100644 v4/java/src/main/resources/templates/home.ftl create mode 100644 v4/java/src/main/resources/templates/index.ftl create mode 100644 v4/java/src/main/resources/templates/standardPage.ftl create mode 100644 v4/java/src/test/java/api/example/java/AppTest.java create mode 100644 v4/python/.gitignore create mode 100644 v4/python/LICENSE create mode 100644 v4/python/README.md create mode 100644 v4/python/climate.py create mode 100644 v4/python/file.py create mode 100644 v4/python/logger.py create mode 100644 v4/python/main.py create mode 100644 v4/python/requirements.txt create mode 100644 v4/python/res/fv-login-button.png diff --git a/v4/.gitignore b/v4/.gitignore new file mode 100644 index 0000000..2f083d2 --- /dev/null +++ b/v4/.gitignore @@ -0,0 +1,11 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build +/bin/ +.DS_Store +.settings +.project +.classpath +.gradle diff --git a/v4/java/.gitignore b/v4/java/.gitignore new file mode 100644 index 0000000..2f083d2 --- /dev/null +++ b/v4/java/.gitignore @@ -0,0 +1,11 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build +/bin/ +.DS_Store +.settings +.project +.classpath +.gradle diff --git a/v4/java/LICENSE b/v4/java/LICENSE new file mode 100644 index 0000000..8be6579 --- /dev/null +++ b/v4/java/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 The Climate Corporation + + 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. diff --git a/v4/java/README.md b/v4/java/README.md new file mode 100644 index 0000000..d7dc24b --- /dev/null +++ b/v4/java/README.md @@ -0,0 +1,31 @@ +# API Example + +Example app exercising some of Climate's [FieldView Platform APIs](https://dev.fieldview.com). + +## Setup +1. Install [Java 8+](https://www.java.com/en/download/) +2. Install [Gradle](https://gradle.org/install/) +3. Set the following environment variables + +```bash +export CLIENT_ID="my-api-id" +export CLIENT_SECRET="azbq56fpadhnt8oukoeani2a4w" +export API_KEY="my-api-id-216b9875-0158-4142-1ab2-7c3bdbd6a2157" +export API_SCOPES="fields:read asPlanted:read" +``` +Regarding scopes - see the [FieldView API technical documentation](https://dev.fieldview.com/technical-documentation/) for more scopes and their +corresponding endpoints (click the `Authorize` button in the swagger docs). + +## Running the web example + +1. Start the server: + +```bash +./gradlew run +``` + +2. Open a browser to [localhost:8080](http://localhost:8080) + +## License + +Copyright © 2019 The Climate Corporation \ No newline at end of file diff --git a/v4/java/build.gradle b/v4/java/build.gradle new file mode 100644 index 0000000..fb4f98b --- /dev/null +++ b/v4/java/build.gradle @@ -0,0 +1,52 @@ +/* + * This generated file contains a sample Java project to get you started. + * For more details take a look at the Java Quickstart chapter in the Gradle + * user guide available at https://docs.gradle.org/5.1.1/userguide/tutorial_java_projects.html + */ + +plugins { + // Apply the java plugin to add support for Java + id 'java' + // Apply the application plugin to add support for building an application + id 'application' + //plugin for eclipse web application + id 'eclipse-wtp' + id 'com.gradle.build-scan' version '2.0.2' + // spring boot + id 'org.springframework.boot' version '2.1.2.RELEASE' + id 'io.spring.dependency-management' version '1.0.6.RELEASE' +} +eclipse { + wtp { + facet { + facets = [] + facet name: 'jst.web', version: '2.4' + facet name: 'jst.java', version: '10' + } + } +} + +repositories { + // Use jcenter for resolving your dependencies. + // You can declare any Maven/Ivy/file repository here. + jcenter() + mavenCentral() + maven { url 'https://plugins.gradle.org/m2/' } + maven { url 'https://repo.spring.io/release' } +} + +dependencies { + // This dependency is found on compile classpath of this component and consumers. + implementation 'com.google.guava:guava:26.0-jre' + implementation 'org.springframework.boot:spring-boot-dependencies:2.0.5.RELEASE' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-freemarker' + implementation 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.projectreactor:reactor-spring:1.0.1.RELEASE' + // Use JUnit test framework + testImplementation 'junit:junit:4.12' + +} +// Define the main class for the application +mainClassName = 'api.example.java.App' diff --git a/v4/java/gradle/wrapper/gradle-wrapper.jar b/v4/java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..87b738cbd051603d91cc39de6cb000dd98fe6b02 GIT binary patch literal 55190 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 z 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 \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/v4/java/gradlew.bat b/v4/java/gradlew.bat new file mode 100644 index 0000000..0f8d593 --- /dev/null +++ b/v4/java/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/v4/java/settings.gradle b/v4/java/settings.gradle new file mode 100644 index 0000000..5b08cf9 --- /dev/null +++ b/v4/java/settings.gradle @@ -0,0 +1,10 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * + * Detailed information about configuring a multi-project build in Gradle can be found + * in the user guide at https://docs.gradle.org/5.1.1/userguide/multi_project_builds.html + */ + +rootProject.name = 'api-example-java' diff --git a/v4/java/src/main/java/api/example/java/App.java b/v4/java/src/main/java/api/example/java/App.java new file mode 100644 index 0000000..84776aa --- /dev/null +++ b/v4/java/src/main/java/api/example/java/App.java @@ -0,0 +1,11 @@ +package api.example.java; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class App { + public static void main(String[] args) { + SpringApplication.run(App.class, args); + } +} diff --git a/v4/java/src/main/java/api/example/java/Config.java b/v4/java/src/main/java/api/example/java/Config.java new file mode 100644 index 0000000..d00bf13 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/Config.java @@ -0,0 +1,104 @@ +package api.example.java; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.Base64Utils; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; + +@Configuration +public class Config { + @Value("${climate.login.server}") + public String loginServer; + + @Value("${climate.login.path}") + public String loginPath; + + @Value("${climate.token.server}") + public String tokenServer; + + @Value("${climate.token.path}") + public String tokenPath; + + @Value("${climate.api.server}") + public String apiServer; + + @Value("${CLIENT_ID}") + public String clientId; + + @Value("${CLIENT_SECRET}") + public String clientSecret; + + @Value("${API_KEY}") + public String apiKey; + + @Value("${API_SCOPES}") + public String scopes; + + public Config() { + if (StringUtils.isEmpty(System.getenv("CLIENT_ID"))) { + throw new IllegalArgumentException("Please set environment variable CLIENT_ID"); + } + if (StringUtils.isEmpty(System.getenv("CLIENT_SECRET"))) { + throw new IllegalArgumentException("Please set environment variable CLIENT_SECRET"); + } + if (StringUtils.isEmpty(System.getenv("API_KEY"))) { + throw new IllegalArgumentException("Please set environment variable API_KEY"); + } + if (StringUtils.isEmpty(System.getenv("API_SCOPES"))) { + throw new IllegalArgumentException("Please set environment variable API_SCOPES"); + } + } + + public String buildOauthLink(String redirectUri) { + /* + * https://climate.com/static/app-login/index.html?scope=${scope} + * &page=oidcauthn&response_type=code&redirect_uri=${redirect_uri} + * &client_id=${client_id} + */ + return UriComponentsBuilder + .newInstance() + .scheme("https") + .host(loginServer) + .path(loginPath) + .queryParam("page", "oidcauthn") + .queryParam("response_type", "code") + .queryParam("scope", scopes) + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .build() + .toUriString(); + } + + public String buildTokenUri() { + // https://api.climate.com/api/oauth/token + return getUriComponentsBuilder(tokenServer, tokenPath); + } + + public String buildAgronomicApiUri(String dataType) { + // https://platform.climate.com/v4/layers + return getUriComponentsBuilder(apiServer, "/v4/layers/" + dataType); + } + + public String getBase64Credentials() { + return "Basic " + new String(Base64Utils.encode((clientId + ":" + clientSecret).getBytes())); + } + + public String buildAgronomicContentsApiUri(String id, String dataType) { + // https://platform.climate.com/v4/layers/id/contents + return getUriComponentsBuilder(apiServer, String.format("/v4/layers/%s/%s/contents", dataType, id)); + } + + private String getUriComponentsBuilder(String host, String path) { + return UriComponentsBuilder.newInstance() + .scheme("https") + .host(host) + .path(path) + .build() + .toString(); + } + public String buildFieldsApiUri() { + // https://platform.climate.com/v4/fields + return getUriComponentsBuilder(apiServer, "/v4/fields"); + } +} \ No newline at end of file diff --git a/v4/java/src/main/java/api/example/java/api/ClimateAPIs.java b/v4/java/src/main/java/api/example/java/api/ClimateAPIs.java new file mode 100644 index 0000000..938bd11 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/api/ClimateAPIs.java @@ -0,0 +1,100 @@ +package api.example.java.api; + +import java.util.Iterator; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import api.example.java.Config; +import api.example.java.model.ActivityResult; +import api.example.java.model.FieldResult; + +@Component +public class ClimateAPIs { + private static Logger logger = LoggerFactory.getLogger(ClimateAPIs.class); + @Autowired + protected RequestClient requestClient; + @Autowired + protected Config config; + + public Iterator getActivityIterator(String uri, String accessToken) { + return new Iterator() { + private boolean hasNext = true; + private String xLimitValue = "30"; + private HttpHeaders responseHeaders = new HttpHeaders(); + private final String xNextToken = "x-next-token"; + private final String xLimit = "x-limit"; + + @Override + public boolean hasNext() { + return hasNext; + } + + @Override + public ActivityResult next() { + return requestClient.getWebClient(uri, accessToken, config.apiKey) + .get() + .header(xLimit, xLimitValue) + .header(xNextToken, nextTokenValue()) + .exchange() + .doOnSuccess(clientResponse -> { + responseHeaders = clientResponse.headers().asHttpHeaders(); + logger.info("Headers -> {}", responseHeaders); + logger.info("Status code -> {}", clientResponse.statusCode()); + if (clientResponse.statusCode() == HttpStatus.PARTIAL_CONTENT + || clientResponse.statusCode() == HttpStatus.OK) { + hasNext = true; + } else { + hasNext = false; + } + }) + .flatMap(res -> res.bodyToMono(ActivityResult.class)) + .block(); + } + + private String nextTokenValue() { + List headers = responseHeaders.get(this.xNextToken); + return headers == null ? "" : headers.get(0); + } + }; + } + + public Iterator getContentIterator(String uri, Integer length, String accessToken) { + return new Iterator() { + private final int BUFFER_LEN = 1024 * 1024; + private int currentLength = 0; + + @Override + public boolean hasNext() { + return currentLength < length; + } + + @Override + public ByteArrayResource next() { + int start = currentLength; + currentLength += BUFFER_LEN; + return requestClient.getWebClient(uri, accessToken, config.apiKey) + .get() + .header(HttpHeaders.RANGE, String.format("bytes=%s-%s", start, currentLength - 1)) + .retrieve() + .bodyToMono(ByteArrayResource.class) + .block(); + } + }; + + } + + public FieldResult getFields(String uri, String accessToken) { + return requestClient.getWebClient(uri, accessToken, config.apiKey) + .get() + .retrieve() + .bodyToMono(FieldResult.class) + .block(); + } +} diff --git a/v4/java/src/main/java/api/example/java/api/ClimateOAuth.java b/v4/java/src/main/java/api/example/java/api/ClimateOAuth.java new file mode 100644 index 0000000..0b89f4b --- /dev/null +++ b/v4/java/src/main/java/api/example/java/api/ClimateOAuth.java @@ -0,0 +1,59 @@ +package api.example.java.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; + +import api.example.java.Config; +import api.example.java.model.TokenResponse; + +@Component +public class ClimateOAuth { + + @Autowired + private Config config; + + @Autowired + private RequestClient requestClient; + + private static Logger logger = LoggerFactory.getLogger(ClimateOAuth.class); + + public TokenResponse getToken(String code, String redirectUri) { + + // grant_type=authorization_code&redirect_uri=${redirect_uri}&code=${code} + MultiValueMap formData = new LinkedMultiValueMap(); + formData.add("code", code); + formData.add("grant_type", "authorization_code"); + formData.add("redirect_uri", redirectUri); + + logger.info("Request body value map: {}", formData.toString()); + + return makeRequest(formData); + } + + private TokenResponse makeRequest(MultiValueMap formData) { + TokenResponse tokenResponse = requestClient.getWebClient(config.buildTokenUri(), config.getBase64Credentials()) + .post() + .body(BodyInserters.fromFormData(formData)) + .retrieve().bodyToMono(TokenResponse.class) + .block(); + return tokenResponse; + } + + public TokenResponse getRefreshToken(String refreshToken) { + + // grant_type=authorization_code&redirect_uri=${redirect_uri}&code=${code} + MultiValueMap formData = new LinkedMultiValueMap(); + formData.add("refresh_token", refreshToken); + formData.add("grant_type", "refresh_token"); + + logger.info("Request body value map: {}", formData.toString()); + + return makeRequest(formData); + } + +} diff --git a/v4/java/src/main/java/api/example/java/api/RequestClient.java b/v4/java/src/main/java/api/example/java/api/RequestClient.java new file mode 100644 index 0000000..4b533d9 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/api/RequestClient.java @@ -0,0 +1,40 @@ +package api.example.java.api; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.codec.LoggingCodecSupport; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +public class RequestClient { + + public WebClient getWebClient(String uri, String accessToken, String apiKey) { + return WebClient.builder() + .baseUrl(uri).exchangeStrategies(exchangeStrategies()) + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.ALL_VALUE) + .defaultHeader("x-api-key", apiKey) + .build(); + } + + public WebClient getWebClient(String uri, String auth) { + return WebClient.builder() + .baseUrl(uri) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .defaultHeader(HttpHeaders.AUTHORIZATION, auth) + .exchangeStrategies(exchangeStrategies()) + .build(); + } + + private ExchangeStrategies exchangeStrategies() { + ExchangeStrategies exchangeStrategies = ExchangeStrategies.withDefaults(); + exchangeStrategies + .messageWriters() + .stream() + .filter(LoggingCodecSupport.class::isInstance) + .forEach(writer -> ((LoggingCodecSupport) writer).setEnableLoggingRequestDetails(true)); + return exchangeStrategies; + } +} diff --git a/v4/java/src/main/java/api/example/java/controllers/AgronomicDataController.java b/v4/java/src/main/java/api/example/java/controllers/AgronomicDataController.java new file mode 100644 index 0000000..35f6f5c --- /dev/null +++ b/v4/java/src/main/java/api/example/java/controllers/AgronomicDataController.java @@ -0,0 +1,81 @@ +package api.example.java.controllers; + +import java.io.IOException; +import java.text.NumberFormat; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import api.example.java.api.ClimateAPIs; +import api.example.java.model.Activity; +import api.example.java.model.ActivityResult; + +@Controller +public class AgronomicDataController extends BaseController { + private static final String ACTIVITIES_PAGE = "activities"; + private static final String ACTIVITIES = ACTIVITIES_PAGE; + private static final String DATA_TYPE = "dataType"; + private static final String ID = "id"; + private static final String DATA = "data"; + @Autowired + private ClimateAPIs climateAPIs; + private static Logger logger = LoggerFactory.getLogger(AgronomicDataController.class); + + @GetMapping("/agronomic") + public String agronomicData(Model model, @RequestParam(DATA) String dataType, HttpServletRequest request) { + logger.info("Listing agronomic {} data" + dataType); + List activities = new ArrayList(); + Iterator activityIterator = climateAPIs.getActivityIterator(agronomicApiUri(dataType), + getAccessTokenFromSession(request)); + while (activityIterator.hasNext()) { + ActivityResult activityResult = activityIterator.next(); + if (activityResult != null) { + List activityList = activityResult.getResults(); + if (activityList != null) { + activities.addAll(activityList); + } + } + } + model.addAttribute(ACTIVITIES, activities); + model.addAttribute(DATA_TYPE, dataType); + return ACTIVITIES_PAGE; + } + + @GetMapping("/agronomic-contents") + public void agronomicDataContent(Model model, @RequestParam(DATA) String dataType, @RequestParam(ID) String id, + @RequestParam("length") String length, HttpServletRequest request, HttpServletResponse response) + throws IOException, ParseException { + logger.info("Fetching contents of agronomic - {} - acitity id - {} length - {}", dataType, id, length); + Number number = NumberFormat.getNumberInstance(java.util.Locale.US).parse(length); + response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); + response.setStatus(HttpServletResponse.SC_OK); + response.addHeader(HttpHeaders.CONTENT_DISPOSITION, + String.format("attachment; filename=\"%s_%s.zip\"", id, dataType)); + ServletOutputStream out = response.getOutputStream(); + Iterator contentItr = climateAPIs.getContentIterator(agronomicContentsApiUri(id, dataType), + number.intValue(), getAccessTokenFromSession(request)); + while (contentItr.hasNext()) { + ByteArrayResource buffer = contentItr.next(); + out.write(buffer.getByteArray()); + } + out.flush(); + out.close(); + } + +} diff --git a/v4/java/src/main/java/api/example/java/controllers/BaseController.java b/v4/java/src/main/java/api/example/java/controllers/BaseController.java new file mode 100644 index 0000000..22dd3f2 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/controllers/BaseController.java @@ -0,0 +1,62 @@ +package api.example.java.controllers; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import api.example.java.Config; +import api.example.java.model.TokenResponse; + +public class BaseController { + protected static final String LOGIN_REDIRECT = "login-redirect"; + protected static final String HOME_PAGE = "home"; + protected static final String INDEX_PAGE = "index"; + protected static final String REDIRECT_TO_HOME_PAGE = "redirect:/"; + protected static final String TOKEN_RESPONSE = "tokenResponse"; + protected static final String CODE = "code"; + protected static final String REFRESH_TOKEN = "refresh_token"; + @Autowired + protected Config config; + + protected String getAccessTokenFromSession(HttpServletRequest request) { + String token = ""; + if (request.getSession().getAttribute(TOKEN_RESPONSE) instanceof TokenResponse) { + TokenResponse tokenResponse = (TokenResponse) request.getSession().getAttribute(TOKEN_RESPONSE); + token = tokenResponse.getAccessToken(); + } + return token; + } + + protected boolean isUserLoggedIn(HttpSession session) { + return session.getAttribute(TOKEN_RESPONSE) != null; + } + + @ExceptionHandler(WebClientResponseException.class) + public ResponseEntity handleWebClientResponseException(WebClientResponseException ex) { + return ResponseEntity.status(ex.getRawStatusCode()).body(ex.getResponseBodyAsString()); + } + + protected void saveTokenResponseInSession(HttpServletRequest request, TokenResponse tokenResponse) { + request.getSession().setAttribute(TOKEN_RESPONSE, tokenResponse); + } + + protected void cleanSession(HttpServletRequest request) { + request.getSession().removeAttribute(TOKEN_RESPONSE); + } + + protected String agronomicApiUri(String dataType) { + return config.buildAgronomicApiUri(dataType); + } + + protected String agronomicContentsApiUri(String id, String dataType) { + return config.buildAgronomicContentsApiUri(id, dataType); + } + + protected String fieldsApiUri() { + return config.buildFieldsApiUri(); + } +} diff --git a/v4/java/src/main/java/api/example/java/controllers/FieldController.java b/v4/java/src/main/java/api/example/java/controllers/FieldController.java new file mode 100644 index 0000000..0ad7cf7 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/controllers/FieldController.java @@ -0,0 +1,29 @@ +package api.example.java.controllers; + +import javax.servlet.http.HttpServletRequest; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import api.example.java.api.ClimateAPIs; +@Controller +public class FieldController extends BaseController { + + private static final String FIELDS = "fields"; + + private static final String FIELDS_PAGE = FIELDS; + @Autowired + private ClimateAPIs climateAPIs; + private static Logger logger = LoggerFactory.getLogger(FieldController.class); + + @GetMapping("/fields") + public String getFields(Model model, HttpServletRequest request) { + logger.info("Fields controller entered"); + model.addAttribute(FIELDS, climateAPIs.getFields(fieldsApiUri(), getAccessTokenFromSession(request)).getResults()); + return FIELDS_PAGE; + } +} diff --git a/v4/java/src/main/java/api/example/java/controllers/HomeController.java b/v4/java/src/main/java/api/example/java/controllers/HomeController.java new file mode 100644 index 0000000..cf2bc38 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/controllers/HomeController.java @@ -0,0 +1,28 @@ +package api.example.java.controllers; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@Controller +public class HomeController extends BaseController { + private static final String LOGIN_URI = "loginUri"; + + @GetMapping("/") + public String home(Model model, HttpSession session, HttpServletRequest request) { + if (isUserLoggedIn(session)) { + return HOME_PAGE; + } + model.addAttribute(LOGIN_URI, config.buildOauthLink( + ServletUriComponentsBuilder + .fromRequest(request) + .pathSegment(LOGIN_REDIRECT) + .build() + .toString())); + return INDEX_PAGE; + } +} diff --git a/v4/java/src/main/java/api/example/java/controllers/LoginController.java b/v4/java/src/main/java/api/example/java/controllers/LoginController.java new file mode 100644 index 0000000..362f2ab --- /dev/null +++ b/v4/java/src/main/java/api/example/java/controllers/LoginController.java @@ -0,0 +1,45 @@ +package api.example.java.controllers; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import api.example.java.api.ClimateOAuth; +import api.example.java.model.TokenResponse; + +@Controller +public class LoginController extends BaseController { + + @Autowired + private ClimateOAuth oAuth; + + @GetMapping("/login-redirect") + public String loginRedirect(Model model, @RequestParam(CODE) String code, HttpServletRequest request) { + model.addAttribute(CODE, code); + TokenResponse tokenResponse = oAuth.getToken(code, request.getRequestURL().toString()); + saveTokenResponseInSession(request, tokenResponse); + model.addAttribute(TOKEN_RESPONSE, tokenResponse); + return HOME_PAGE; + } + + @GetMapping("/refresh-token") + public String refreshToken(Model model, @RequestParam(REFRESH_TOKEN) String refreshToken, + HttpServletRequest request) { + + TokenResponse tokenResponse = oAuth.getRefreshToken(refreshToken); + saveTokenResponseInSession(request, tokenResponse); + model.addAttribute(TOKEN_RESPONSE, tokenResponse); + return HOME_PAGE; + } + + @GetMapping("/logout") + public String logout(Model model, HttpServletRequest request) { + cleanSession(request); + return REDIRECT_TO_HOME_PAGE; + } + +} diff --git a/v4/java/src/main/java/api/example/java/model/Activity.java b/v4/java/src/main/java/api/example/java/model/Activity.java new file mode 100644 index 0000000..b053b2f --- /dev/null +++ b/v4/java/src/main/java/api/example/java/model/Activity.java @@ -0,0 +1,70 @@ +package api.example.java.model; + +import java.util.List; + +public class Activity { + private String id; + private String startTime; + private String endTime; + private String createdAt; + private String updatedAt; + private int length; + private List fieldIds; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt; + } + + public int getLength() { + return length; + } + + public void setLength(int length) { + this.length = length; + } + + public List getFieldIds() { + return fieldIds; + } + + public void setFieldIds(List fieldIds) { + this.fieldIds = fieldIds; + } + +} diff --git a/v4/java/src/main/java/api/example/java/model/ActivityResult.java b/v4/java/src/main/java/api/example/java/model/ActivityResult.java new file mode 100644 index 0000000..c577cc6 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/model/ActivityResult.java @@ -0,0 +1,17 @@ +package api.example.java.model; + +import java.util.List; + +public class ActivityResult { + + private List results; + + public List getResults() { + return results; + } + + public void setResults(List results) { + this.results = results; + } + +} diff --git a/v4/java/src/main/java/api/example/java/model/Field.java b/v4/java/src/main/java/api/example/java/model/Field.java new file mode 100644 index 0000000..0321fb4 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/model/Field.java @@ -0,0 +1,50 @@ +package api.example.java.model; + +public class Field { + private String id; + private String name; + private String boundaryId; + private String resourceOwnerId; + private Parent parent; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getBoundaryId() { + return boundaryId; + } + + public void setBoundaryId(String boundaryId) { + this.boundaryId = boundaryId; + } + + public String getResourceOwnerId() { + return resourceOwnerId; + } + + public void setResourceOwnerId(String resourceOwnerId) { + this.resourceOwnerId = resourceOwnerId; + } + + public Parent getParent() { + return parent; + } + + public void setParent(Parent parent) { + this.parent = parent; + } + +} diff --git a/v4/java/src/main/java/api/example/java/model/FieldResult.java b/v4/java/src/main/java/api/example/java/model/FieldResult.java new file mode 100644 index 0000000..061daf9 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/model/FieldResult.java @@ -0,0 +1,16 @@ +package api.example.java.model; + +import java.util.List; + +public class FieldResult { + private List results; + + public List getResults() { + return results; + } + + public void setResults(List results) { + this.results = results; + } + +} diff --git a/v4/java/src/main/java/api/example/java/model/Parent.java b/v4/java/src/main/java/api/example/java/model/Parent.java new file mode 100644 index 0000000..4284595 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/model/Parent.java @@ -0,0 +1,23 @@ +package api.example.java.model; + +public class Parent { + private String id; + private String type; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + +} diff --git a/v4/java/src/main/java/api/example/java/model/TokenResponse.java b/v4/java/src/main/java/api/example/java/model/TokenResponse.java new file mode 100644 index 0000000..569975c --- /dev/null +++ b/v4/java/src/main/java/api/example/java/model/TokenResponse.java @@ -0,0 +1,50 @@ +package api.example.java.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class TokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("refresh_token") + private String responseToken; + + @JsonProperty("scope") + private String scopes; + + private User user; + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return responseToken; + } + + public void setResponseToken(String responseToken) { + this.responseToken = responseToken; + } + + public String getScopes() { + return scopes; + } + + public void setScopes(String scopes) { + this.scopes = scopes; + } + +} diff --git a/v4/java/src/main/java/api/example/java/model/User.java b/v4/java/src/main/java/api/example/java/model/User.java new file mode 100644 index 0000000..c2245b4 --- /dev/null +++ b/v4/java/src/main/java/api/example/java/model/User.java @@ -0,0 +1,42 @@ +package api.example.java.model; + +public class User { + + private String email; + private String firstName; + private String lastName; + private String country; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getFirstname() { + return firstName; + } + + public void setFirstname(String firstName) { + this.firstName = firstName; + } + + public String getLastname() { + return lastName; + } + + public void setLastname(String lastName) { + this.lastName = lastName; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + +} diff --git a/v4/java/src/main/resources/application.properties b/v4/java/src/main/resources/application.properties new file mode 100644 index 0000000..b9d2799 --- /dev/null +++ b/v4/java/src/main/resources/application.properties @@ -0,0 +1,12 @@ +spring.freemarker.template-loader-path: classpath:/templates +spring.freemarker.suffix: .ftl +spring.devtools.restart.additional-paths = src/main/resources/templates +spring.freemarker.cache = false +climate.login.server = climate.com +climate.login.path = /static/app-login/index.html +climate.token.server = api.climate.com +climate.token.path = /api/oauth/token +climate.api.server = platform.climate.com +logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions=TRACE +spring.http.log-request-details=true +spring.output.ansi.enabled=ALWAYS \ No newline at end of file diff --git a/v4/java/src/main/resources/static/favicon.png b/v4/java/src/main/resources/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..bac278b38827c1729807107b82451e46b7afdd01 GIT binary patch literal 7054 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 + +<@standardPage title="Api Java Example"> +

    Partner API Demo Site

    +

    Welcome to the Climate Partner Demo App.

    + + + + + + + + + + + <#list activities as activity> + + + + + + + + + + +
    IdStart DateEnd Date Created AtUpdated AtLengthField Ids
    + ${activity.id} + ${activity.startTime}${activity.endTime}${activity.createdAt}${activity.updatedAt}${activity.length}[ + <#list activity.fieldIds as field> + ${field}  + ] +
    + \ No newline at end of file diff --git a/v4/java/src/main/resources/templates/fields.ftl b/v4/java/src/main/resources/templates/fields.ftl new file mode 100644 index 0000000..71161b1 --- /dev/null +++ b/v4/java/src/main/resources/templates/fields.ftl @@ -0,0 +1,28 @@ +<#include "standardPage.ftl" /> + +<@standardPage title="Api Java Example"> +

    Partner API Demo Site

    +

    Welcome to the Climate Partner Demo App.

    + + + + + + + + + + <#list fields as field> + + + + + + + + + +
    IdnameBoundaryIdResourceOwnerIdParentIdParentType
    + ${field.id} + ${field.name}${field.boundaryId}${field.resourceOwnerId}${field.parent.id}${field.parent.type}
    + \ No newline at end of file diff --git a/v4/java/src/main/resources/templates/footer.ftl b/v4/java/src/main/resources/templates/footer.ftl new file mode 100644 index 0000000..a640126 --- /dev/null +++ b/v4/java/src/main/resources/templates/footer.ftl @@ -0,0 +1,28 @@ +<#if logout_link?has_content> + Logout + +<#if tokenResponse?has_content> + Logout +  | + Home +  | + Refresh Token + <#if tokenResponse.scopes?has_content> + <#if tokenResponse.scopes?contains("fields:read")> +  | + Fields + + <#if tokenResponse.scopes?contains("asPlanted:read")> +  | + asPlanted + + <#if tokenResponse.scopes?contains("asApplied:read")> +  | + asApplied + + <#if tokenResponse.scopes?contains("asHarvested:read")> +  | + asHarvested + + + diff --git a/v4/java/src/main/resources/templates/home.ftl b/v4/java/src/main/resources/templates/home.ftl new file mode 100644 index 0000000..8127abd --- /dev/null +++ b/v4/java/src/main/resources/templates/home.ftl @@ -0,0 +1,18 @@ +<#include "standardPage.ftl" /> + +<@standardPage title="Api Java Example"> +

    Partner API Demo Site

    +

    Welcome to the Climate Partner Demo App.

    +

    + Welcome - ${tokenResponse.user.firstname} ${tokenResponse.user.lastname} +

    +

    + Email - ${tokenResponse.user.email}
    + Country - ${tokenResponse.user.country} +

    +

    + Access Token - ${tokenResponse.accessToken}
    + Refresh Token - ${tokenResponse.refreshToken}
    + Scopes - ${tokenResponse.scopes} +

    + \ No newline at end of file diff --git a/v4/java/src/main/resources/templates/index.ftl b/v4/java/src/main/resources/templates/index.ftl new file mode 100644 index 0000000..3893dd2 --- /dev/null +++ b/v4/java/src/main/resources/templates/index.ftl @@ -0,0 +1,13 @@ +<#include "standardPage.ftl" /> + +<@standardPage title="Api Java Example"> +

    Partner API Demo Site

    +

    Welcome to the Climate Partner Demo App.

    +

    Imagine that this page is your great web application and you want + to connect it with Climate FieldView. To do this, you need to let your + users establish a secure connection between your app and FieldView. You + do this using Log In with FieldView.

    +

    + +

    + diff --git a/v4/java/src/main/resources/templates/standardPage.ftl b/v4/java/src/main/resources/templates/standardPage.ftl new file mode 100644 index 0000000..645670b --- /dev/null +++ b/v4/java/src/main/resources/templates/standardPage.ftl @@ -0,0 +1,29 @@ +<#macro standardPage title=""> + + + + ${title} + + + + + + <#nested/> + <#include "footer.ftl"> + + + \ No newline at end of file diff --git a/v4/java/src/test/java/api/example/java/AppTest.java b/v4/java/src/test/java/api/example/java/AppTest.java new file mode 100644 index 0000000..607bc1a --- /dev/null +++ b/v4/java/src/test/java/api/example/java/AppTest.java @@ -0,0 +1,11 @@ + +package api.example.java; + +import org.junit.Test; + +public class AppTest { + @Test + public void testApp() { + // App classUnderTest = new App(); + } +} diff --git a/v4/python/.gitignore b/v4/python/.gitignore new file mode 100644 index 0000000..b05546e --- /dev/null +++ b/v4/python/.gitignore @@ -0,0 +1,92 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# venv +api-example/ diff --git a/v4/python/LICENSE b/v4/python/LICENSE new file mode 100644 index 0000000..8be6579 --- /dev/null +++ b/v4/python/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 The Climate Corporation + + 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. diff --git a/v4/python/README.md b/v4/python/README.md new file mode 100644 index 0000000..5a8c08d --- /dev/null +++ b/v4/python/README.md @@ -0,0 +1,55 @@ +# API Example + +Example app exercising some of Climate's [FieldView API](https://dev.fieldview.com). + +## Setup + +1. Install python 3.6+. + +```bash +# if you use Mac OS X and brew, this can be done with: +brew install python3 +``` + +2. Make a virtual environment, activate it, and install dependencies. + +```bash +python3 -m venv api-example +source api-example/bin/activate +pip install -r requirements.txt +``` + +3. Set the following environment variables (or hardcode them in `main.py`) +to the values provided to you by Climate: + +```bash +export CLIMATE_API_ID="my-api-id" +export CLIMATE_API_SECRET="azbq56fpadhnt8oukoeani2a4w" +export CLIMATE_API_KEY="my-api-id-216b9875-0158-4142-1ab2-7c3bdbd6a2157" +export CLIMATE_API_SCOPES="openid fields:read imagery:write" +``` + +Regarding scopes - see the [FieldView API technical documentation](https://dev.fieldview.com/technical-documentation/) for more scopes and their +corresponding endpoints (click the `Authorize` button in the swagger docs). + +4. When you're done running the example, deactivate the virtual environment with: + +```bash +deactivate +``` + +## Running the web example + +1. Follow steps in _Setup_ + +2. Start the server: + +```bash +python3 main.py +``` + +3. Open a browser to [localhost:8080/home](http://localhost:8080/home) + +## License + +Copyright © 2018 The Climate Corporation diff --git a/v4/python/climate.py b/v4/python/climate.py new file mode 100644 index 0000000..37065bd --- /dev/null +++ b/v4/python/climate.py @@ -0,0 +1,568 @@ +""" +Climate API demo code. This module shows how to: + +- Log in with Climate +- Refresh the access_token +- Fetch fields +- Fetch field boundaries +- Upload files + +License: +Copyright © 2018 The Climate Corporation +""" + +import requests + +import file +import os +from base64 import b64encode +from urllib.parse import urlencode +from curlify import to_curl +from logger import Logger + + +json_content_type = 'application/json' +binary_content_type = 'application/octet-stream' +metadata_content_types = ['application/vnd.climate.as-applied.zip'] + +base_login_uri = 'https://climate.com/static/app-login/index.html' +token_uri = 'https://api.climate.com/api/oauth/token' +api_uri = 'https://platform.climate.com' +CHUNK_SIZE = 5 * 1024 * 1024 + + +def login_uri(client_id, scopes, redirect_uri): + """ + Builds the URI for 'Log In with FieldView' link. + The redirect_uri is a uri on your system (this app) that will handle the + authorization once the user has authenticated with FieldView. + """ + params = { + 'scope': scopes, + 'page': 'oidcauthn', + 'response_type': 'code', + 'client_id': client_id, + 'redirect_uri': redirect_uri + } + return '{}?{}'.format(base_login_uri, urlencode(params)) + + +def authorization_header(client_id, client_secret): + """ + Builds the authorization header unique to your company or application. + :param client_id: Provided by Climate. + :param client_secret: Provided by Climate. + :return: Basic authorization header. + """ + pair = '{}:{}'.format(client_id, client_secret) + encoded = b64encode(pair.encode('ascii')).decode('ascii') + return 'Basic {}'.format(encoded) + + +def authorize(login_code, client_id, client_secret, redirect_uri): + """ + Exchanges the login code provided on the redirect request for an + access_token and refresh_token. Also gets user data. + :param login_code: Authorization code returned from Log In with FieldView + on redirect uri. + :param client_id: Provided by Climate. + :param client_secret: Provided by Climate. + :param redirect_uri: Uri to your redirect page. Needs to be the same as + the redirect uri provided in the initial Log In with FieldView request. + :return: Object containing user data, access_token and refresh_token. + """ + headers = { + 'authorization': authorization_header(client_id, client_secret), + 'content-type': 'application/x-www-form-urlencoded', + 'accept': 'application/json' + } + data = { + 'grant_type': 'authorization_code', + 'redirect_uri': redirect_uri, + 'code': login_code + } + res = requests.post(token_uri, headers=headers, data=urlencode(data)) + Logger().info(to_curl(res.request)) + if res.status_code == 200: + return res.json() + + Logger().error("Auth failed: %s" % res.status_code) + Logger().error("Auth failed: %s" % res.json()) + return None + + +def reauthorize(refresh_token, client_id, client_secret): + """ + Access_tokens expire after 4 hours. At any point before the end of that + period you may request a new access_token (and refresh_token) by submitting + a POST request to the /api/oauth/token end-point. Note that the data + submitted is slightly different than on initial authorization. Refresh + tokens are good for 30 days from their date of issue. Once this end-point + is called, the refresh token that is passed to this call is immediately set + to expired one hour from "now" and the newly issues refresh token will + expire 30 days from "now". Make sure to store the new refresh token so you + can use it in the future to get a new auth tokens as needed. If you lose + the refresh token there is no effective way to retrieve a new refresh token + without having the user log in again. + :param refresh_token: refresh_token supplied by initial + (or subsequent refresh) call. + :param client_id: Provided by Climate. + :param client_secret: Provided by Climate. + :return: Object containing user data, access_token and refresh_token. + """ + headers = { + 'authorization': authorization_header(client_id, client_secret), + 'content-type': 'application/x-www-form-urlencoded', + 'accept': 'application/json' + } + data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token + } + res = requests.post(token_uri, headers=headers, data=urlencode(data)) + Logger().info(to_curl(res.request)) + if res.status_code == 200: + return res.json() + + log_http_error(res) + return None + + +def bearer_token(token): + """ + Returns content of authorization header to be provided on all non-auth + API calls. + :param token: access_token returned from authorization call. + :return: Formatted header. + """ + return 'Bearer {}'.format(token) + + +def get_fields(token, api_key, next_token=None): + """ + Retrieve a user's field list from Climate. Note that fields + (like most data) is paginated to support very large + data sets. If the status code returned is 206 (partial content), then + there is more data to get. The x-next-token header provides a "marker" + that can be used on another request to get the next page of data. + Continue fetching data until the status is 200. Note that x-next-token + is based on date modified, so storing x-next-token can used as a method + to fetch updates over longer periods of time (though also note that this + will not result in fetching deleted objects since they no longer appear in + lists regardless of their modified date). + :param token: access_token + :param api_key: Provided by Climate. + :param next_token: Pagination token from previous request, or None. + :return: A (possibly empty) list of fields. + """ + uri = '{}/v4/fields'.format(api_uri) + headers = { + 'authorization': bearer_token(token), + 'accept': json_content_type, + 'x-api-key': api_key, + 'x-next-token': next_token + } + + res = requests.get(uri, headers=headers) + Logger().info(to_curl(res.request)) + + if res.status_code == 200: + return res.json()['results'] + if res.status_code == 206: + next_token = res.headers['x-next-token'] + return res.json()['results'] + get_fields(token, api_key, next_token) + + log_http_error(res) + return [] + + +def get_boundary(boundary_id, token, api_key): + """ + Retrieve field boundary from Climate. Note that boundary objects are + immutable, so whenever a field's boundary is updated the boundaryId + property of the field will change and you will need to fetch the + updated boundary. + :param boundary_id: UUID of field boundary to retrieve. + :param token: access_token + :param api_key: Provided by Climate + :return: geojson object representing the boundary of the field. + """ + uri = '{}/v4/boundaries/{}'.format(api_uri, boundary_id) + headers = { + 'authorization': bearer_token(token), + 'accept': json_content_type, + 'x-api-key': api_key + } + + res = requests.get(uri, headers=headers) + Logger().info(to_curl(res.request)) + + if res.status_code == 200: + return res.json() + + log_http_error(res) + return None + + +def upload(f, content_type, token, api_key): + """Upload a file with the given content type to Climate + + This example supports files up to 5 MiB (5,242,880 bytes). + + Returns The upload id if the upload is successful, False otherwise. + """ + uri = '{}/v4/uploads'.format(api_uri) + headers = { + 'authorization': bearer_token(token), + 'x-api-key': api_key + } + md5 = file.md5(f) + length = file.length(f) + data = { + 'md5': md5, + 'length': length, + 'contentType': content_type + } + + if any(content_type in ct for ct in metadata_content_types): + data['metadata'] = { + 'fileName' : f.filename + } + + # initiate upload + res = requests.post(uri, headers=headers, json=data) + Logger().info(to_curl(res.request)) + + if res.status_code == 201: + upload_id = res.json() + Logger().info("Upload Id: %s" % upload_id) + put_uri = '{}/{}'.format(uri, upload_id) + + # for this example, size is assumed to be small enough for a + # single upload (less than or equal to 5 MiB) + headers['content-range'] = 'bytes {}-{}/{}'.format(0, + (length - 1), + length) + headers['content-type'] = binary_content_type + + f.seek(0) + + # send image + for position in range(0, length, CHUNK_SIZE): + buf = f.read(CHUNK_SIZE) + headers['content-range'] = 'bytes {}-{}/{}'.format( + position, position + len(buf) - 1, length) + try: + res = requests.put(put_uri, headers=headers, data=buf) + Logger().info(headers) + except Exception as e: + Logger().error("Exception: %s" % e) + + if res.status_code == 204: + return upload_id + + return False + + +def get_upload_status(upload_id, token, api_key): + """ + Retrieve the status of an upload. See + https://dev.fieldview.com/technical-documentation/ for possible status + values and their meaning. + :param upload_id: id of upload + :param token: access_token + :param api_key: Provided by Climate + :return: status json object containing upload id and status. + """ + uri = '{}/v4/uploads/{}/status'.format(api_uri, upload_id) + headers = { + 'authorization': bearer_token(token), + 'accept': json_content_type, + 'x-api-key': api_key + } + + res = requests.get(uri, headers=headers) + Logger().info(to_curl(res.request)) + + if res.status_code == 200: + return res.json() + + log_http_error(res) + return None + + +def get_scouting_observations(token, + api_key, + limit=100, + next_token=None, + occurred_after=None, + occurred_before=None): + """ + Retrieve a list of scouting observations created or updated by the user + identified by the Authorization header. + https://dev.fieldview.com/technical-documentation/ for possible status + values and their meaning. + :param token: access_token + :param api_key: Provided by Climate + :param next-token: Opaque string which allows for fetching the next batch + of results. + :param limit: Max number of results to return per batch. Must be between + 1 and 100 inclusive. + :param occurred_after: Optional start time by which to filter layer + results. + :param occurred_before: Optional end time by which to filter layer results. + :return: status json object containing scouting observation list + and status. + """ + uri = '{}/v4/layers/scoutingObservations'.format(api_uri) + headers = { + 'authorization': bearer_token(token), + 'accept': json_content_type, + 'x-api-key': api_key, + 'x-limit': str(limit), + 'x-next-token': next_token + } + params = { + 'occurredAfter': occurred_after, + 'occurredBefore': occurred_before + } + + res = requests.get(uri, headers=headers, params=params) + Logger().info(to_curl(res.request)) + + if res.status_code == 200: + return res.json()['results'] + if res.status_code == 206: + next_token = res.headers['x-next-token'] + return res.json()['results'] + \ + get_scouting_observations(token, + api_key, + limit, + next_token, + occurred_after, + occurred_before) + log_http_error(res) + return [] + + +def get_scouting_observation(token, api_key, scouting_observation_id): + """ + Retrieve an individual scouting observation by id. Ids are retrieved via + the /layers/scoutingObservations route. + https://dev.fieldview.com/technical-documentation/ for possible status + values and their meaning. + :param token: access_token + :param api_key: Provided by Climate + :param scouting_observation_id: Unique identifier of the + Scouting Observation. + + """ + uri = '{}/v4/layers/scoutingObservations/{}'.format( + api_uri, scouting_observation_id) + headers = { + 'authorization': bearer_token(token), + 'accept': json_content_type, + 'x-api-key': api_key + } + + res = requests.get(uri, headers=headers) + Logger().info(to_curl(res.request)) + + if res.status_code == 200: + return res.json() + + log_http_error(res) + return None + + +def get_scouting_observation_attachments(token, + api_key, + scouting_observation_id): + """ + Retrieve attachments associated with a given scouting observation. Photos + added to scouting notes in the FieldView app are capped to 20MB, and we + won’t store photos larger than that in a scouting note. + https://dev.fieldview.com/technical-documentation/ for possible status + values and their meaning. + :param token: access_token + :param api_key: Provided by Climate + :param scouting_observation_id: Unique identifier of the + Scouting Observation. + + """ + uri = '{}/v4/layers/scoutingObservations/{}/attachments'.format( + api_uri, scouting_observation_id) + headers = { + 'authorization': bearer_token(token), + 'accept': json_content_type, + 'x-api-key': api_key + } + + res = requests.get(uri, headers=headers) + Logger().info(to_curl(res.request)) + + if res.status_code == 200: + return res.json()['results'] + + log_http_error(res) + return [] + + +def log_http_error(response): + + """ + Private function to log errors on server console + :param response: http response object. + """ + if response.status_code == 403: + Logger().error("Permission error, current scopes are - {}".format( + os.environ['CLIMATE_API_SCOPES'])) + elif response.status_code == 400: + Logger().error("Bad request - {}".format(response.text)) + elif response.status_code == 401: + Logger().error("Unauthorized - {}".format(response.text)) + elif response.status_code == 404: + Logger().error("Resource not found - {}".format(response.text)) + elif response.status_code == 416: + Logger().error("Range Not Satisfiable - {}".format(response.text)) + elif response.status_code == 500: + Logger().error("Internal server error - {}".format(response.text)) + elif response.status_code == 503: + Logger().error("Server busy - {}".format(response.text)) + + +def get_scouting_observation_attachments_contents(token, + api_key, + scouting_observation_id, + attachment_id, + content_type, + length): + """ + Retrieve the binary contents of a scouting observation’s attachment. + https://dev.fieldview.com/technical-documentation/ for possible status + values and their meaning. + :param token: access_token + :param api_key: Provided by Climate + :param scouting_observation_id: Unique identifier of the Scouting + Observation. + :param attachment_id : Unique identifiler of the attachment + + """ + + uri = '{}/v4/layers/scoutingObservations/{}/attachments/{}/contents'.\ + format(api_uri, + scouting_observation_id, + attachment_id) + + headers = { + 'authorization': bearer_token(token), + 'accept': content_type, + 'x-api-key': api_key, + } + + return fetch_contents(uri, headers, length) + + +def get_as_planted(token, api_key, next_token): + """ + Retrieve as Planted activities + :param token: access_token + :param api_key: Provided by Climate + :param next_token: Opaque string which allows for fetching the next batch + of results. + """ + return get_activities(token, api_key, next_token, "asPlanted") + + +def get_as_harvested(token, api_key, next_token): + """ + Retrieve as Harvested activities + :param token: access_token + :param api_key: Provided by Climate + :param next_token: Opaque string which allows for fetching the next batch + of results. + """ + + return get_activities(token, api_key, next_token, "asHarvested") + + +def get_as_applied(token, api_key, next_token): + """ + Retrieve as Applied activities + :param token: access_token + :param api_key: Provided by Climate + :param next_token: Opaque string which allows for fetching the next batch + of results. + """ + + return get_activities(token, api_key, next_token, "asApplied") + + +def get_activities(token, api_key, next_token, activity): + """ + Retrieve a list of field activities. + https://dev.fieldview.com/technical-documentation/ for possible status + values and their meaning. + :param token: access_token + :param api_key: Provided by Climate + :param next-token: Opaque string which allows for fetching the next batch + of results. + :param activity: name of activity + + """ + uri = '{}/v4/layers/{}'.format(api_uri, activity) + + headers = { + 'authorization': bearer_token(token), + 'x-api-key': api_key, + 'x-next-token': next_token, + 'x-limit': str(10) + } + + res = requests.get(uri, headers=headers) + + if res.status_code == 200: + return None, res.json()['results'] + if res.status_code == 206: + return res.headers['x-next-token'], res.json()['results'] + if res.status_code == 304: + return None, None + + log_http_error(res) + + return None, None + + +def get_activity_contents(token, api_key, layer_id, activity_id, length): + """ + Retrieve a content of field activity. + https://dev.fieldview.com/technical-documentation/ for possible status + values and their meaning. + :param token: access_token + :param api_key: Provided by Climate + :param layer_id: name of activity + :param activity_id: id of activity + :param length: content length + + """ + uri = '{}/v4/layers/{}/{}/contents'.format(api_uri, layer_id, activity_id) + + headers = { + 'authorization': bearer_token(token), + 'x-api-key': api_key, + } + + return fetch_contents(uri, headers, length) + + +def fetch_contents(uri, headers, length): + chunk_size = 1 * 1024 * 1024 + for start in range(0, length, chunk_size): + end = min(length, start + chunk_size) + headers['Range'] = 'bytes={}-{}'.format(start, end - 1) + res = requests.get(uri, headers=headers) + if res.status_code == 200 or res.status_code == 206: + yield res.content + else: + log_http_error(res) + break diff --git a/v4/python/file.py b/v4/python/file.py new file mode 100644 index 0000000..0655341 --- /dev/null +++ b/v4/python/file.py @@ -0,0 +1,28 @@ +""" +File utilities + +License: +Copyright © 2018 The Climate Corporation +""" + +import hashlib +import os + + +def length(f): + """Get the length of a file""" + f.seek(0, os.SEEK_END) + length = f.tell() + return length + + +def md5(f): + """Get the md5 of a file's contents""" + f.seek(0) + md5 = hashlib.md5() + chunk_size = 2**10 + + for bytes_chunk in iter(lambda: f.read(chunk_size), b''): + md5.update(bytes_chunk) + + return md5.hexdigest() diff --git a/v4/python/logger.py b/v4/python/logger.py new file mode 100644 index 0000000..2159ebe --- /dev/null +++ b/v4/python/logger.py @@ -0,0 +1,28 @@ +import logging +import sys + + +class Logger: + """ + Simple singleton class to encapsulate logging to the Flask app object so + we can have unified logging. + """ + instance = None + + def __new__(cls, logger=None): + if not Logger.instance: + if not logger: + raise ValueError("No logger specified on creation of Logger\ + singleton.") + Logger.instance = logger + logger.setLevel(logging.INFO) + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.INFO) + formatter = logging.Formatter('%(levelname)s - %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + return Logger.instance + return Logger.instance + + def __getattr__(self, name): + return getattr(self.instance, name) diff --git a/v4/python/main.py b/v4/python/main.py new file mode 100644 index 0000000..9365482 --- /dev/null +++ b/v4/python/main.py @@ -0,0 +1,574 @@ +""" +Partner example app + +Start a simple app server: + - allow user login via Climate + - display user's Climate fields + - retrieve field boundary info + - basic file upload to Climate + +We use Flask in this example to provide a simple HTTP server. You will notice +that some of the functions in this file are decorated with @app.route() which +registers them with Flask as functions to service requests to the specified +URIs. + +This file (main.py) provides the web UI and framework for the demo app. All +the work with the Climate API happens in climate.py. + +Note: For this example, only one "user" can be logged into the example app at +a time. + +License: +Copyright © 2018 The Climate Corporation +""" + +import json +import os +from logger import Logger + +from flask import Flask, request, redirect, url_for, send_from_directory +from flask import Response, stream_with_context +import climate + +# Configuration of your Climate partner credentials. This assumes you have +# placed them in your environment. You may +# also choose to just hard code them here if you prefer. + +CLIMATE_API_ID = os.environ['CLIMATE_API_ID'] # OAuth2 client ID +CLIMATE_API_SECRET = os.environ['CLIMATE_API_SECRET'] # OAuth2 client secret +CLIMATE_API_SCOPES = os.environ['CLIMATE_API_SCOPES'] # Oauth2 scope list +CLIMATE_API_KEY = os.environ['CLIMATE_API_KEY'] # X-Api-Key header +# Partner app server + +app = Flask(__name__) +logger = Logger(app.logger) + +# User state - only one user at a time. In your application this would be +# handled by your session management and backing +# storage. + +_state = {} + + +def set_state(**kwargs): + global _state + if 'access_token' in kwargs: + _state['access_token'] = kwargs['access_token'] + if 'refresh_token' in kwargs: + _state['refresh_token'] = kwargs['refresh_token'] + if 'user' in kwargs: + _state['user'] = kwargs['user'] + if 'fields' in kwargs: + _state['fields'] = kwargs['fields'] + + +def clear_state(): + set_state(access_token=None, refresh_token=None, user=None, fields=None) + + +def state(key): + global _state + return _state.get(key) + + +# Routes + + +@app.route('/home') +def home(): + if state('user'): + return user_homepage() + return no_user_homepage() + + +def no_user_homepage(): + """ + This is logically the first place a user will come. On your site it will + be some page where you present them with a link to Log In with FieldView. + The main thing here is that you provide a correctly formulated link with + the required parameters and correct button image. + :return: None + """ + url = climate.login_uri(CLIMATE_API_ID, CLIMATE_API_SCOPES, redirect_uri()) + return """ +

    Partner API Demo Site

    +

    Welcome to the Climate Partner Demo App.

    +

    Imagine that this page is your great web application and you + want to connect it with Climate FieldView. To do this, you need + to let your users establish a secure connection between your app + and FieldView. You do this using Log In with FieldView.

    +

    +

    """.format(url) + + +def user_homepage(): + """ + This page just demonstrates some basic Climate FieldView API operations + such as getting field details, accessing user information and and + refreshing the authorization token. + :return: None + """ + field_list = render_ul(render_field_link(f) for f in state('fields')) + return """ +

    Partner API Demo Site

    +

    User name retrieved from FieldView: {first} {last}

    +

    Access Token: {access_token}

    +

    Refresh Token: {refresh_token} + (Refresh)

    +
    +

    Your Climate fields:{fields}

    +

    Upload data

    +

    Scouting Observations

    +
    +

    Your fields activities:

    +

    asPlanted

    +

    asHarvested

    +

    asApplied

    +
    +

    Log out

    + """.format(first=state('user')['firstname'], + last=state('user')['lastname'], + access_token=state('access_token'), + refresh_token=state('refresh_token'), + fields=field_list, + upload=url_for('upload_form'), + logout=url_for('logout_redirect'), + refresh=url_for('refresh_token'), + scouting_observations=url_for('scouting_observations'), + as_planted=url_for('as_planted'), + as_harvested=url_for('as_harvested'), + as_applied=url_for('as_applied')) + + +@app.route('/login-redirect') +def login_redirect(): + """ + This is the page a user will come back to after having successfully logged + in with FieldView. The URI was provided as one of the parameters to the + login URI above. The "code" parameter in the URI's query string contains + the access_token and refresh_token. + :return: + """ + code = request.args['code'] + if code: + resp = climate.authorize(code, + CLIMATE_API_ID, + CLIMATE_API_SECRET, + redirect_uri()) + if resp: + # Store tokens and user in state for subsequent requests. + access_token = resp['access_token'] + refresh_token = resp['refresh_token'] + set_state(user=resp['user'], + access_token=access_token, + refresh_token=refresh_token) + + # Fetch fields and store in state just for example purposes. You + # might well do this at the time of need, + # or not at all depending on your app. + fields = climate.get_fields(access_token, CLIMATE_API_KEY) + set_state(fields=fields) + + return redirect(url_for('home')) + + +@app.route('/refresh-token') +def refresh_token(): + """ + This route doesn't have any page associated with it; it just refreshes the + authorization token and redirects back to the home page. As a by-product, + this also refreshes the user data. + :return: + """ + resp = climate.reauthorize(state('refresh_token'), + CLIMATE_API_ID, + CLIMATE_API_SECRET) + if resp: + # Store tokens and user in state for subsequent requests. + access_token = resp['access_token'] + refresh_token = resp['refresh_token'] + set_state(user=resp['user'], access_token=access_token, + refresh_token=refresh_token) + + return redirect(url_for('home')) + + +@app.route('/logout-redirect') +def logout_redirect(): + """ + Clears all current user data. Does not make any Climate API calls. + :return: + """ + clear_state() + return redirect(url_for('home')) + + +@app.route('/field/') +def field(field_id): + """ + Shows how to fetch field boundary information and displays it as raw + geojson data. + :param field_id: + :return: + """ + field = [f for f in state('fields') if f['id'] == field_id][0] + + boundary = climate.get_boundary(field['boundaryId'], + state('access_token'), + CLIMATE_API_KEY) + + return """ +

    Partner API Demo Site

    +

    Field Name: {name}

    +

    Boundary info:

    {boundary}

    +

    Return home

    + """.format(name=field['name'], + boundary=json.dumps(boundary, indent=4, sort_keys=True), + home=url_for('home')) + + +@app.route('/upload', methods=['GET', 'POST']) +def upload_form(): + """ + Initially (when method=GET) render the upload form to collect information + about the file to upload.When the form is POSTed, invoke the actual Climate + API code to do the chunked upload. + :return: + """ + if request.method == 'POST': + if 'file' not in request.files or request.files['file'].stream is None: + return redirect(url_for('upload_form')) + + f = request.files['file'] + content_type = request.form['file_content_type'] + upload_id = climate.upload( + f, content_type, state('access_token'), CLIMATE_API_KEY) + + return """ +

    Partner API Demo Site

    +

    Upload data

    +

    File uploaded: {upload_id} + Get Status

    +

    Return home

    + """.format(upload_id=upload_id, + status_url=url_for( + 'update_status', upload_id=upload_id), + home=url_for('home')) + + return """ +

    Partner API Demo Site

    +

    Upload data

    +
    +

    Content type:

    +

    +

    +
    +

    Return home

    + """.format(home=url_for('home')) + + +@app.route('/upload/', methods=['GET']) +def update_status(upload_id): + """ + Shows the status of an upload. Uploads are processed asynchronously so to + know if an upload was successful you need to check its status until it is + either in the INBOX or SUCCESS state (it worked) or the INVALID state + (it failed). This method demonstrates the API call to get the status for + a single upload id. There is also a call to get stattus for a list + of upload ids. + :param upload_id: uuid of upload returned by API. + :return: + """ + status = climate.get_upload_status(upload_id, + state('access_token'), + CLIMATE_API_KEY) + + return """ +

    Partner API Demo Site

    +

    Upload ID: {upload_id}

    +

    Status: {status} + Refresh

    +

    Return home

    + """.format(upload_id=upload_id, + status=status.get('status'), + home=url_for('home')) + + +# Various utilities just to make the demo app work. No Climate API stuff here. + + +@app.route('/res/') +def send_res(path): + """ + Sends a static resource. + """ + return send_from_directory('res', path) + + +def render_ul(xs): + return '
      {}
    '.format('\n'.join('
  • {}
  • '.format(x) for x in xs)) + + +def render_field_link(field): + field_id = field['id'] + return '{name} ({id})'.format( + link=url_for('field', field_id=field_id), + name=field['name'], + id=field_id) + + +def render_scouting_observation_link(scouting_observation): + oid = scouting_observation['id'] + return '{oid}'.format( + link=url_for('scouting_observation', scouting_observation_id=oid), + oid=oid) + + +def render_attachment_link(scouting_observation_id, attachment): + attachment_id = attachment['id'] + if attachment['status'] == 'DELETED': + link = '' + else: + link = ': Get contents'.format( + link=url_for('scouting_observation_attachments_contents', + scouting_observation_id=scouting_observation_id, + attachment_id=attachment_id, + contentType=attachment['contentType'], + length=attachment['length'])) + + return """

    {attachment_id}{link}

    +

    {info}

    + """.format(link=link, + attachment_id=attachment_id, + info=json.dumps(attachment, indent=4, sort_keys=True)) + + +def render_activitiy_link(activity, link): + activity_id = activity['id'] + link = '{link}/{activity_id}/contents?length={length}'.format( + link=link, + activity_id=activity_id, + length=activity['length']) + + return """ + {activity_id} : Get contents +

    {body}

    + """.format(activity_id=activity_id, + link=link, + body=json.dumps(activity, indent=4, sort_keys=True)) + + +def redirect_uri(): + """ + :return: Returns uri for redirection after Log In with FieldView. + """ + return url_for('login_redirect', _external=True) + + +@app.route('/scouting-observation/', methods=['GET']) +def scouting_observation(scouting_observation_id): + """ + Shows the details of a scouting observation + :param scouting_observation_id: a scouting observation identifier + + :return: returns the html response + """ + observation = climate.get_scouting_observation(state('access_token'), + CLIMATE_API_KEY, + scouting_observation_id) + return """ +

    Partner API Demo Site

    +

    Scouting Observation ID: {scouting_observation_id}

    +

    {json}

    +

    List attachments

    +

    Return to Observations list

    +

    Return home

    + """.format(scouting_observation_id=scouting_observation_id, + json=json.dumps(observation, indent=4, sort_keys=True), + observations=url_for('scouting_observations'), + attachments=url_for( + 'scouting_observation_attachments', + scouting_observation_id=scouting_observation_id), + home=url_for('home')) + + +@app.route('/scouting-observations', methods=['GET']) +def scouting_observations(): + """ + Displays the list of scouting observations + + :return: returns the html response which shows list of observations + """ + observations = climate.get_scouting_observations(state('access_token'), + CLIMATE_API_KEY, + 100) + body = "

    No Scouting Observations found!

    " + if observations: + scouting_observations = render_ul( + render_scouting_observation_link(o) for o in observations) + body = "

    Your Climate Scouting Observations:\ + {scouting_observations}

    ".format( + scouting_observations=scouting_observations) + + return """ +

    Partner API Demo Site

    + {body} +

    Return home

    + """.format(body=body, home=url_for('home')) + + +@app.route('/scouting-observation//attachments', + methods=['GET']) +def scouting_observation_attachments(scouting_observation_id): + """ + Shows the list of attachments for a given scouting observation + :param scouting_observation_id: a scouting observation identifier + :return: returns html which shows list of attachments. + """ + ats = climate.get_scouting_observation_attachments(state('access_token'), + CLIMATE_API_KEY, + scouting_observation_id) + + body = "

    No attachments found!

    " + if ats: + attachments = render_ul( + render_attachment_link(scouting_observation_id, a) for a in ats) + body = "

    Your Climate Scouting Observations attachments:\ + {attachments}

    ".format( + attachments=attachments) + + return """ +

    Partner API Demo Site

    + {body} +

    Return to Observation:{soid}

    +

    Return home

    + """.format(body=body, + home=url_for('home'), + attachments=url_for( + 'scouting_observation', + scouting_observation_id=scouting_observation_id), + soid=scouting_observation_id) + + +@app.route( + '/scouting-observation/' + '/attachments/', + methods=['GET']) +def scouting_observation_attachments_contents(scouting_observation_id, + attachment_id): + """ + Downloads the attachment contents + :param scouting_observation_id: a scouting observation identifier + :param attachment_id: an attachment identifier + :return: returns contents of attachment. + """ + content_type = request.args.get('contentType') + length = int(request.args.get('length')) + # stream the content back to client + headers = { + 'Content-type': 'image/jpeg' + } + content = climate.get_scouting_observation_attachments_contents( + state('access_token'), + CLIMATE_API_KEY, + scouting_observation_id, + attachment_id, + content_type, + length + ) + return Response(response=content, headers=headers) + + +def get_callee(activity): + method = 'get_{}'.format(activity) + return getattr(climate, method) + + +def handle_activity(activity): + + next_token = request.args.get('next_token') + has_more_records, activities = get_callee(activity)( + state('access_token'), + CLIMATE_API_KEY, + next_token) + + body = "

    No data found!

    " + + if activities is not None: + activities_list = render_ul(render_activitiy_link( + a, url_for(activity)) for a in activities) + body = "

    Your Climate {activity} activities:{activities_list}

    "\ + .format(activities_list=activities_list, activity=activity) + + more_records_html = "" + if has_more_records is not None: + next_link = url_for(activity, next_token=has_more_records) + more_records_html = "

    More records >>\ +

    ".format(next_link=next_link) + return """ +

    Partner API Demo Site

    + {body} + {more_records_html} +

    Return home

    + """.format(body=body, + home=url_for('home'), + more_records_html=more_records_html) + + +@app.route('/layers/asPlanted', methods=['GET']) +def as_planted(): + """ + Shows list of planting activities + :return: returns planting activities. + """ + return handle_activity("as_planted") + + +@app.route('/layers/asHarvested', methods=['GET']) +def as_harvested(): + """ + Shows list of harvesting activities + :return: returns harvesting activities. + """ + return handle_activity("as_harvested") + + +@app.route('/layers/asApplied', methods=['GET']) +def as_applied(): + """ + Shows list of application activities + :return: returns application activities. + """ + return handle_activity("as_applied") + + +@app.route('/layers///contents') +def get_activity_contents(layer_id, activity_id): + """ + Download the contents of given activity + :param layer_id: name of activity + :param activity_id: id of activity + :return: returns contents of given activity. + """ + length = int(request.args.get('length')) + content = climate.get_activity_contents( + state('access_token'), + CLIMATE_API_KEY, + layer_id, + activity_id, + length) + response = Response(stream_with_context(content), + mimetype='application/zip') + response.headers['Content-Disposition'] = 'attachment; filename=data.zip' + return response + + +# start app + + +if __name__ == '__main__': + clear_state() + app.run( + host="localhost", + port=8080 + ) diff --git a/v4/python/requirements.txt b/v4/python/requirements.txt new file mode 100644 index 0000000..602b85f --- /dev/null +++ b/v4/python/requirements.txt @@ -0,0 +1,8 @@ +click==6.7 +Flask==1.1.1 +itsdangerous==0.24 +Jinja2==2.11.3 +MarkupSafe==1.0 +requests==2.20.0 +Werkzeug==0.15.5 +curlify==1.2.1 diff --git a/v4/python/res/fv-login-button.png b/v4/python/res/fv-login-button.png new file mode 100644 index 0000000000000000000000000000000000000000..d20b7f882d2e181c1806bd65a76d7e50bf499c85 GIT binary patch literal 2314 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 Date: Tue, 22 Mar 2022 14:17:24 -0700 Subject: [PATCH 3/4] create v5 sample python app --- v5/python/.gitignore | 95 +++++++++ v5/python/climate.py | 262 +++++++++++++++++++++++ v5/python/logger.py | 28 +++ v5/python/main.py | 306 +++++++++++++++++++++++++++ v5/python/requirements.txt | 15 ++ v5/python/static/fv-login-button.png | Bin 0 -> 2314 bytes 6 files changed, 706 insertions(+) create mode 100644 v5/python/.gitignore create mode 100644 v5/python/climate.py create mode 100644 v5/python/logger.py create mode 100644 v5/python/main.py create mode 100644 v5/python/requirements.txt create mode 100644 v5/python/static/fv-login-button.png diff --git a/v5/python/.gitignore b/v5/python/.gitignore new file mode 100644 index 0000000..065992b --- /dev/null +++ b/v5/python/.gitignore @@ -0,0 +1,95 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# venv +api-example/ + +# env scripts +set_env_variables.sh \ No newline at end of file diff --git a/v5/python/climate.py b/v5/python/climate.py new file mode 100644 index 0000000..d0bb661 --- /dev/null +++ b/v5/python/climate.py @@ -0,0 +1,262 @@ +""" +Climate API demo code. This module shows how to: + +- Log in with Climate +- Refresh the access_token +- Fetch fields +- Fetch field boundaries +- Upload files + +License: +Copyright © 2018 The Climate Corporation +""" + +import requests + +import os +import json +from base64 import b64encode +from urllib.parse import urlencode +from curlify import to_curl +from logger import Logger + + +json_content_type = 'application/json' +binary_content_type = 'application/octet-stream' + +base_login_uri = 'https://climate.com/static/app-login/index.html' +token_uri = 'https://api.climate.com/api/oauth/token' +api_uri = 'https://platform.climate.com' + + +def login_uri(client_id, scopes, redirect_uri): + """ + Builds the URI for 'Log In with FieldView' link. + The redirect_uri is a uri on your system (this app) that will handle the + authorization once the user has authenticated with FieldView. + """ + params = { + 'scope': scopes, + 'page': 'oidcauthn', + 'response_type': 'code', + 'client_id': client_id, + 'redirect_uri': redirect_uri + } + return '{}?{}'.format(base_login_uri, urlencode(params)) + + +def authorization_header(client_id, client_secret): + """ + Builds the authorization header unique to your company or application. + :param client_id: Provided by Climate. + :param client_secret: Provided by Climate. + :return: Basic authorization header. + """ + pair = '{}:{}'.format(client_id, client_secret) + encoded = b64encode(pair.encode('ascii')).decode('ascii') + return 'Basic {}'.format(encoded) + + +def authorize(login_code, client_id, client_secret, redirect_uri): + """ + Exchanges the login code provided on the redirect request for an + access_token and refresh_token. Also gets user data. + :param login_code: Authorization code returned from Log In with FieldView + on redirect uri. + :param client_id: Provided by Climate. + :param client_secret: Provided by Climate. + :param redirect_uri: Uri to your redirect page. Needs to be the same as + the redirect uri provided in the initial Log In with FieldView request. + :return: Object containing user data, access_token and refresh_token. + """ + headers = { + 'authorization': authorization_header(client_id, client_secret), + 'content-type': 'application/x-www-form-urlencoded', + 'accept': 'application/json' + } + data = { + 'grant_type': 'authorization_code', + 'redirect_uri': redirect_uri, + 'code': login_code + } + res = requests.post(token_uri, headers=headers, data=urlencode(data)) + Logger().info(to_curl(res.request)) + if res.status_code == 200: + return res.json() + + Logger().error("Auth failed: %s" % res.status_code) + Logger().error("Auth failed: %s" % res.json()) + return None + + +def reauthorize(refresh_token, client_id, client_secret): + """ + Access_tokens expire after 4 hours. At any point before the end of that + period you may request a new access_token (and refresh_token) by submitting + a POST request to the /api/oauth/token end-point. Note that the data + submitted is slightly different than on initial authorization. Refresh + tokens are good for 30 days from their date of issue. Once this end-point + is called, the refresh token that is passed to this call is immediately set + to expired one hour from "now" and the newly issues refresh token will + expire 30 days from "now". Make sure to store the new refresh token so you + can use it in the future to get a new auth tokens as needed. If you lose + the refresh token there is no effective way to retrieve a new refresh token + without having the user log in again. + :param refresh_token: refresh_token supplied by initial + (or subsequent refresh) call. + :param client_id: Provided by Climate. + :param client_secret: Provided by Climate. + :return: Object containing user data, access_token and refresh_token. + """ + headers = { + 'authorization': authorization_header(client_id, client_secret), + 'content-type': 'application/x-www-form-urlencoded', + 'accept': 'application/json' + } + data = { + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token + } + res = requests.post(token_uri, headers=headers, data=urlencode(data)) + Logger().info(to_curl(res.request)) + if res.status_code == 200: + return res.json() + + log_http_error(res) + return None + + +def bearer_token(token): + """ + Returns content of authorization header to be provided on all non-auth + API calls. + :param token: access_token returned from authorization call. + :return: Formatted header. + """ + return 'Bearer {}'.format(token) + + +def log_http_error(response): + """ + Private function to log errors on server console + :param response: http response object. + """ + if response.status_code == 403: + Logger().error("Permission error, current scopes are - {}".format( + os.environ['CLIMATE_API_SCOPES'])) + elif response.status_code == 400: + Logger().error("Bad request - {}".format(response.text)) + elif response.status_code == 401: + Logger().error("Unauthorized - {}".format(response.text)) + elif response.status_code == 404: + Logger().error("Resource not found - {}".format(response.text)) + elif response.status_code == 416: + Logger().error("Range Not Satisfiable - {}".format(response.text)) + elif response.status_code == 500: + Logger().error("Internal server error - {}".format(response.text)) + elif response.status_code == 503: + Logger().error("Server busy - {}".format(response.text)) + + +def growingSeasons(field_id, token, api_key): + """ + Retrieve the growing seasons from Climate. + :param field_id: UUID of field to retrieve. + :param token: access_token + :param api_key: Provided by Climate + :return: JSON object with a contentId which is the growingSeasonsContentId + """ + uri = '{}/v5/growingSeasons'.format(api_uri) + headers = { + 'authorization': bearer_token(token), + 'x-api-key': api_key + } + data = { + 'fieldId': field_id + } + + res = requests.post(uri, headers=headers, json=data) + Logger().info(to_curl(res.request)) + + if res.status_code == 202: + content_id = res.json()['contentId'] + Logger().info("Growing Seasons Content Id: %s" % content_id) + return content_id + log_http_error(res) + return False + + +def growingSeasonsContents(content_id, token, api_key): + """ + Retrieve the growing seasons contents from Climate. + :param content_id: UUID of growingSeasonsContentsId to retrieve. + :param token: access_token + :param api_key: Provided by Climate + :return: JSON object with a list of UUID of the growingSeasonsId and year + """ + uri = '{}/v5/growingSeasonsContents/{}'.format(api_uri, content_id) + headers = { + 'authorization': bearer_token(token), + 'x-api-key': api_key + } + res = requests.get(uri, headers=headers) + Logger().info(to_curl(res.request)) + + if res.status_code == 200: + pretty_json = json.loads(res.text) + return json.dumps(pretty_json, indent=4, sort_keys=True) + log_http_error(res) + return False + + +def harvestReports(field_id, seasons, token, api_key): + """ + Retrieve the harvest reports from Climate. + :param field_id: UUID of field to retrieve. + :param seasons: UUID of growingSeasonsId to retrieve. + :param token: access_token + :param api_key: Provided by Climate + :return: JSON object with a harvest report id + """ + uri = '{}/v5/harvestReports'.format(api_uri) + headers = { + 'authorization': bearer_token(token), + 'x-api-key': api_key + } + data = { + 'fieldId': field_id, + 'growingSeasons': seasons + } + + res = requests.post(uri, headers=headers, json=data) + Logger().info(to_curl(res.request)) + + if res.status_code == 202: + report_id = res.json()['id'] + Logger().info("Report Id: %s" % report_id) + return report_id + log_http_error(res) + return False + + +def harvestReportsContents(report_id, token, api_key): + """ + Retrieve the harvest reports contents from Climate. + :param report_id: UUID of harvest report id to retrieve. + :param token: access_token + :param api_key: Provided by Climate + :return: JSON object with a list harvest reports + """ + uri = '{}/v5/harvestReportsContents/{}'.format(api_uri, report_id) + headers = { + 'authorization': bearer_token(token), + 'x-api-key': api_key + } + res = requests.get(uri, headers=headers) + Logger().info(to_curl(res.request)) + + if res.status_code == 200: + pretty_json = json.loads(res.text) + return json.dumps(pretty_json, indent=4, sort_keys=True) + log_http_error(res) + return False diff --git a/v5/python/logger.py b/v5/python/logger.py new file mode 100644 index 0000000..2159ebe --- /dev/null +++ b/v5/python/logger.py @@ -0,0 +1,28 @@ +import logging +import sys + + +class Logger: + """ + Simple singleton class to encapsulate logging to the Flask app object so + we can have unified logging. + """ + instance = None + + def __new__(cls, logger=None): + if not Logger.instance: + if not logger: + raise ValueError("No logger specified on creation of Logger\ + singleton.") + Logger.instance = logger + logger.setLevel(logging.INFO) + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.INFO) + formatter = logging.Formatter('%(levelname)s - %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + return Logger.instance + return Logger.instance + + def __getattr__(self, name): + return getattr(self.instance, name) diff --git a/v5/python/main.py b/v5/python/main.py new file mode 100644 index 0000000..609945e --- /dev/null +++ b/v5/python/main.py @@ -0,0 +1,306 @@ +""" +Partner example app + +Start a simple app server: + - allow user login via Climate + - retrieve harvest activity + - retrieve growing seasons + +We use Flask in this example to provide a simple HTTP server. You will notice +that some of the functions in this file are decorated with @app.route() which +registers them with Flask as functions to service requests to the specified +URIs. + +This file (main.py) provides the web UI and framework for the demo app. All +the work with the Climate API happens in climate.py. + +Note: For this example, only one "user" can be logged into the example app at +a time. + +License: +Copyright © 2018 The Climate Corporation +""" + +import json +import os +from logger import Logger + +from flask import Flask, request, redirect, url_for +import climate + +# Configuration of your Climate partner credentials. This assumes you have +# placed them in your environment. You may +# also choose to just hard code them here if you prefer. + +CLIMATE_API_ID = os.environ['CLIMATE_API_ID'] # OAuth2 client ID +CLIMATE_API_SECRET = os.environ['CLIMATE_API_SECRET'] # OAuth2 client secret +CLIMATE_API_SCOPES = os.environ['CLIMATE_API_SCOPES'] # Oauth2 scope list +CLIMATE_API_KEY = os.environ['CLIMATE_API_KEY'] # X-Api-Key header +# Partner app server + +app = Flask(__name__) +logger = Logger(app.logger) + +# User state - only one user at a time. In your application this would be +# handled by your session management and backing +# storage. + +_state = {} + + +def set_state(**kwargs): + global _state + if 'access_token' in kwargs: + _state['access_token'] = kwargs['access_token'] + if 'refresh_token' in kwargs: + _state['refresh_token'] = kwargs['refresh_token'] + if 'user' in kwargs: + _state['user'] = kwargs['user'] + + +def clear_state(): + set_state(access_token=None, refresh_token=None, user=None, fields=None) + + +def state(key): + global _state + return _state.get(key) + +# Routes + + +@app.route('/') +def home(): + if state('user'): + return user_homepage() + return no_user_homepage() + + +def no_user_homepage(): + """ + This is logically the first place a user will come. On your site it will + be some page where you present them with a link to Log In with FieldView. + The main thing here is that you provide a correctly formulated link with + the required parameters and correct button image. + :return: None + """ + url = climate.login_uri(CLIMATE_API_ID, CLIMATE_API_SCOPES, redirect_uri()) + return """ +

    Partner API Demo Site

    +

    Welcome to the Climate Partner Demo App.

    +

    Imagine that this page is your great web application and you + want to connect it with Climate FieldView. To do this, you need + to let your users establish a secure connection between your app + and FieldView. You do this using Log In with FieldView.

    +

    + FieldView Login

    """.format(url) + + +def user_homepage(): + """ + This page just demonstrates some basic Climate FieldView API operations + such as getting field details, accessing user information and and + refreshing the authorization token. + :return: None + """ + return """ +

    Partner API Demo Site

    +

    User name retrieved from FieldView: {first} {last}

    +

    Access Token: {access_token}

    +

    Refresh Token: {refresh_token} + (Refresh)

    +
    +

    Growing Seasons

    +

    Harvest Reports

    +
    +

    Log out

    + """.format(first=state('user')['firstname'], + last=state('user')['lastname'], + access_token=state('access_token'), + refresh_token=state('refresh_token'), + refresh=url_for('refresh_token'), + logout=url_for('logout_redirect'), + growing_seasons=url_for('growing_seasons'), + harvest_reports=url_for('harvest_reports')) + + +@app.route('/login-redirect') +def login_redirect(): + """ + This is the page a user will come back to after having successfully logged + in with FieldView. The URI was provided as one of the parameters to the + login URI above. The "code" parameter in the URI's query string contains + the access_token and refresh_token. + :return: + """ + code = request.args['code'] + if code: + resp = climate.authorize(code, + CLIMATE_API_ID, + CLIMATE_API_SECRET, + redirect_uri()) + if resp: + # Store tokens and user in state for subsequent requests. + access_token = resp['access_token'] + refresh_token = resp['refresh_token'] + set_state(user=resp['user'], + access_token=access_token, + refresh_token=refresh_token) + + return redirect(url_for('home')) + + +def redirect_uri(): + """ + :return: Returns uri for redirection after Log In with FieldView. + """ + return url_for('login_redirect', _external=True) + + +@app.route('/refresh-token') +def refresh_token(): + """ + This route doesn't have any page associated with it; it just refreshes the + authorization token and redirects back to the home page. As a by-product, + this also refreshes the user data. + :return: + """ + resp = climate.reauthorize(state('refresh_token'), + CLIMATE_API_ID, + CLIMATE_API_SECRET) + if resp: + # Store tokens and user in state for subsequent requests. + access_token = resp['access_token'] + refresh_token = resp['refresh_token'] + set_state(user=resp['user'], access_token=access_token, + refresh_token=refresh_token) + + return redirect(url_for('home')) + + +@app.route('/logout-redirect') +def logout_redirect(): + """ + Clears all current user data. Does not make any Climate API calls. + :return: + """ + clear_state() + return redirect(url_for('home')) + + +@app.route('/growingSeasons', methods=['GET', 'POST']) +def growing_seasons(): + """ + Initially (when method=GET) render the growing seasons form to collect + information necessary for the growingSeasons request. When the form is + POSTed, invoke the actual Climate API. + :return: HTML form or the Growing Seasons Id + """ + if request.method == 'POST': + field_id = request.form['field_id'] + growing_seasons_id = climate.growingSeasons( + field_id, state('access_token'), CLIMATE_API_KEY) + growing_seasons_contents_url = url_for( + 'growing_seasons_contents', growing_seasons_id=growing_seasons_id) + + return """ +

    Partner API Demo Site

    +

    Growing Seasons

    +

    Growing Seasons Ids: + {growing_seasons_id} +

    Return home

    + """.format( + growing_seasons_contents_url=growing_seasons_contents_url, + growing_seasons_id=growing_seasons_id, + home=url_for('home')) + + return """ +

    Partner API Demo Site

    +

    Growing Seasons

    +
    +

    Field ID:

    +

    +
    +

    Return home

    + """.format(home=url_for('home')) + + +@app.route('/growingSeasonsContents/', methods=['GET']) +def growing_seasons_contents(growing_seasons_id): + """ + This page shows the growing seasons content id and year of the growing + season + :return: response from Climate's GrowingSeasonsConents API + """ + response = climate.growingSeasonsContents( + growing_seasons_id, state('access_token'), CLIMATE_API_KEY) + return """

    Partner API Demo Site

    +

    Growing Seasons Contents

    +
    {response}
    +

    Return home

    """.format( + response=response, home=url_for('home')) + + +@app.route('/harvestReports', methods=['GET', 'POST']) +def harvest_reports(): + """ + Initially (when method=GET) render the harvest reports form to collect + information necessary for the harvestReports request. When the form is + POSTed, invoke the actual Climate API. + :return: HTML form or the Harvest Reports Id + """ + if request.method == 'POST': + field_id = request.form['field_id'] + seasons = request.form['seasons'].replace(' ', '').split(',') + harvest_report_id = climate.harvestReports( + field_id, seasons, state('access_token'), CLIMATE_API_KEY) + harvest_report_contents_url = url_for( + 'harvest_report_contents', harvest_report_id=harvest_report_id) + + return """ +

    Partner API Demo Site

    +

    Harvest Report

    +

    Harvest Report Id: + {harvest_report_id}

    +

    Return home

    + """.format(harvest_report_contents_url=harvest_report_contents_url, + harvest_report_id=harvest_report_id, + home=url_for('home')) + return """ +

    Partner API Demo Site

    +

    Harvest Report

    +
    +

    Field ID:

    + + +

    +
    +

    Return home

    + """.format(home=url_for('home')) + + +@app.route('/harvestReportsContents/', methods=['GET']) +def harvest_report_contents(harvest_report_id): + """ + This page shows the harvest reports of the inputted growing seasons + :return: response from Climate's harvestReportsContents API + """ + response = climate.harvestReportsContents( + harvest_report_id, state('access_token'), CLIMATE_API_KEY) + return """

    Partner API Demo Site

    +

    Harvest Reports Contents

    +
    {response}
    +

    Return home

    """.format( + response=response, home=url_for('home')) + + +# start app + + +if __name__ == '__main__': + clear_state() + app.run( + host="localhost", + port=8080 + ) diff --git a/v5/python/requirements.txt b/v5/python/requirements.txt new file mode 100644 index 0000000..f567ec4 --- /dev/null +++ b/v5/python/requirements.txt @@ -0,0 +1,15 @@ +autopep8==1.6.0 +certifi==2021.10.8 +charset-normalizer==2.0.12 +click==8.0.4 +curlify==2.2.1 +Flask==2.0.3 +idna==3.3 +itsdangerous==2.1.1 +Jinja2==3.0.3 +MarkupSafe==2.1.1 +pycodestyle==2.8.0 +requests==2.27.1 +toml==0.10.2 +urllib3==1.26.9 +Werkzeug==2.0.3 diff --git a/v5/python/static/fv-login-button.png b/v5/python/static/fv-login-button.png new file mode 100644 index 0000000000000000000000000000000000000000..d20b7f882d2e181c1806bd65a76d7e50bf499c85 GIT binary patch literal 2314 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 Date: Thu, 24 Mar 2022 14:51:46 -0700 Subject: [PATCH 4/4] create a function to render reused text and links --- v5/python/climate.py | 16 ++--- v5/python/main.py | 138 ++++++++++++++++++++++--------------------- 2 files changed, 75 insertions(+), 79 deletions(-) diff --git a/v5/python/climate.py b/v5/python/climate.py index d0bb661..0299d3b 100644 --- a/v5/python/climate.py +++ b/v5/python/climate.py @@ -3,12 +3,11 @@ - Log in with Climate - Refresh the access_token -- Fetch fields -- Fetch field boundaries -- Upload files +- Fetch growing seasons +- Fetch harvest reports License: -Copyright © 2018 The Climate Corporation +Copyright © 2022 Climate, LLC """ import requests @@ -22,7 +21,6 @@ json_content_type = 'application/json' -binary_content_type = 'application/octet-stream' base_login_uri = 'https://climate.com/static/app-login/index.html' token_uri = 'https://api.climate.com/api/oauth/token' @@ -179,9 +177,7 @@ def growingSeasons(field_id, token, api_key): Logger().info(to_curl(res.request)) if res.status_code == 202: - content_id = res.json()['contentId'] - Logger().info("Growing Seasons Content Id: %s" % content_id) - return content_id + return res.json()['contentId'] log_http_error(res) return False @@ -232,9 +228,7 @@ def harvestReports(field_id, seasons, token, api_key): Logger().info(to_curl(res.request)) if res.status_code == 202: - report_id = res.json()['id'] - Logger().info("Report Id: %s" % report_id) - return report_id + return res.json()['id'] log_http_error(res) return False diff --git a/v5/python/main.py b/v5/python/main.py index 609945e..5c8a32a 100644 --- a/v5/python/main.py +++ b/v5/python/main.py @@ -18,7 +18,7 @@ a time. License: -Copyright © 2018 The Climate Corporation +Copyright © 2022 Climate, LLC """ import json @@ -66,6 +66,14 @@ def state(key): global _state return _state.get(key) + +def page(content): + return """ +

    Partner API Demo Site

    + {content} +

    Return home

    + """.format(content=content, home=url_for('home')) + # Routes @@ -85,16 +93,18 @@ def no_user_homepage(): :return: None """ url = climate.login_uri(CLIMATE_API_ID, CLIMATE_API_SCOPES, redirect_uri()) - return """ -

    Partner API Demo Site

    -

    Welcome to the Climate Partner Demo App.

    + + content = """ +

    Welcome to the Climate Partner Demo App.

    Imagine that this page is your great web application and you want to connect it with Climate FieldView. To do this, you need to let your users establish a secure connection between your app and FieldView. You do this using Log In with FieldView.

    FieldView Login

    """.format(url) + alt="FieldView Login">

    + """.format(url) + return page(content) def user_homepage(): @@ -104,25 +114,25 @@ def user_homepage(): refreshing the authorization token. :return: None """ - return """ -

    Partner API Demo Site

    -

    User name retrieved from FieldView: {first} {last}

    -

    Access Token: {access_token}

    -

    Refresh Token: {refresh_token} - (Refresh)

    -
    -

    Growing Seasons

    -

    Harvest Reports

    -
    -

    Log out

    - """.format(first=state('user')['firstname'], - last=state('user')['lastname'], - access_token=state('access_token'), - refresh_token=state('refresh_token'), - refresh=url_for('refresh_token'), - logout=url_for('logout_redirect'), - growing_seasons=url_for('growing_seasons'), - harvest_reports=url_for('harvest_reports')) + content = """ +

    User name retrieved from FieldView: {first} {last}

    +

    Access Token: {access_token}

    +

    Refresh Token: {refresh_token} + (Refresh)

    +
    +

    Growing Seasons

    +

    Harvest Reports

    +
    +

    Log out

    + """.format(first=state('user')['firstname'], + last=state('user')['lastname'], + access_token=state('access_token'), + refresh_token=state('refresh_token'), + refresh=url_for('refresh_token'), + logout=url_for('logout_redirect'), + growing_seasons=url_for('growing_seasons'), + harvest_reports=url_for('harvest_reports')) + return page(content) @app.route('/login-redirect') @@ -203,27 +213,22 @@ def growing_seasons(): field_id, state('access_token'), CLIMATE_API_KEY) growing_seasons_contents_url = url_for( 'growing_seasons_contents', growing_seasons_id=growing_seasons_id) - - return """ -

    Partner API Demo Site

    + content = """

    Growing Seasons

    Growing Seasons Ids: {growing_seasons_id} -

    Return home

    - """.format( + """.format( growing_seasons_contents_url=growing_seasons_contents_url, - growing_seasons_id=growing_seasons_id, - home=url_for('home')) - - return """ -

    Partner API Demo Site

    -

    Growing Seasons

    -
    -

    Field ID:

    -

    -
    -

    Return home

    - """.format(home=url_for('home')) + growing_seasons_id=growing_seasons_id) + return page(content) + content = """ +

    Growing Seasons

    +
    +

    Field ID:

    +

    +
    + """ + return page(content) @app.route('/growingSeasonsContents/', methods=['GET']) @@ -235,11 +240,11 @@ def growing_seasons_contents(growing_seasons_id): """ response = climate.growingSeasonsContents( growing_seasons_id, state('access_token'), CLIMATE_API_KEY) - return """

    Partner API Demo Site

    -

    Growing Seasons Contents

    -
    {response}
    -

    Return home

    """.format( - response=response, home=url_for('home')) + content = """ +

    Growing Seasons Contents

    +
    {}
    + """.format(response) + return page(content) @app.route('/harvestReports', methods=['GET', 'POST']) @@ -257,27 +262,24 @@ def harvest_reports(): field_id, seasons, state('access_token'), CLIMATE_API_KEY) harvest_report_contents_url = url_for( 'harvest_report_contents', harvest_report_id=harvest_report_id) - - return """ -

    Partner API Demo Site

    + content = """

    Harvest Report

    Harvest Report Id: {harvest_report_id}

    Return home

    - """.format(harvest_report_contents_url=harvest_report_contents_url, - harvest_report_id=harvest_report_id, - home=url_for('home')) - return """ -

    Partner API Demo Site

    -

    Harvest Report

    -
    -

    Field ID:

    - - -

    -
    -

    Return home

    - """.format(home=url_for('home')) + """.format(harvest_report_contents_url=harvest_report_contents_url, + harvest_report_id=harvest_report_id) + return page(content) + content = """ +

    Harvest Report

    +
    +

    Field ID:

    + + +

    +
    + """ + return page(content) @app.route('/harvestReportsContents/', methods=['GET']) @@ -288,11 +290,11 @@ def harvest_report_contents(harvest_report_id): """ response = climate.harvestReportsContents( harvest_report_id, state('access_token'), CLIMATE_API_KEY) - return """

    Partner API Demo Site

    -

    Harvest Reports Contents

    -
    {response}
    -

    Return home

    """.format( - response=response, home=url_for('home')) + content = """ +

    Harvest Reports Contents

    +
    {response}
    + """.format(response=response) + return page(content) # start app