From efe7adea2a4a94530a843a9505f4b0139231a560 Mon Sep 17 00:00:00 2001 From: jothepro Date: Wed, 17 Nov 2021 22:59:21 +0100 Subject: [PATCH] initial commit - move sourcecode from gitlab - make request-interface private again - adapt github actions to requirements - adapt github actions to upload to gitlab repo - start working on documentation - apply GPLv3 License - update doxygen-awesome-css --- .../configureBuildTestCreateAndUpload.yaml | 21 +- CMakeLists.txt | 8 +- Doxyfile | 12 +- LICENSE | 695 +++++++++++++++++- README.md | 164 ++--- cmake/modules/GetVersion.cmake | 4 +- conanfile.py | 33 +- docs/doxygen-awesome-css | 2 +- docs/doxygen-custom/custom.css | 2 +- docs/doxygen-custom/header.html | 12 +- docs/img/logo.drawio.svg | 2 +- example/CMakeLists.txt | 18 + example/main.cpp | 210 ++++++ lib/CMakeLists.txt | 141 ++++ lib/include/CloudSync/Cloud.hpp | 135 ++++ lib/include/CloudSync/CloudFactory.hpp | 86 +++ lib/include/CloudSync/Credentials.hpp | 25 + lib/include/CloudSync/Directory.hpp | 88 +++ lib/include/CloudSync/Exceptions.hpp | 16 + lib/include/CloudSync/File.hpp | 79 ++ lib/include/CloudSync/OAuth2Credentials.hpp | 33 + lib/include/CloudSync/Proxy.hpp | 29 + lib/include/CloudSync/Resource.hpp | 64 ++ .../CloudSync/UsernamePasswordCredentials.hpp | 21 + lib/src/Cloud.cpp | 17 + lib/src/CloudFactory.cpp | 53 ++ lib/src/CloudImpl.cpp | 40 + lib/src/CloudImpl.hpp | 20 + lib/src/Directory.cpp | 12 + lib/src/OAuth2Credentials.cpp | 17 + lib/src/Proxy.cpp | 12 + lib/src/ResourceImpl.hpp | 30 + lib/src/UsernamePasswordCredentials.cpp | 10 + lib/src/box/BoxCloud.hpp | 52 ++ lib/src/box/BoxDirectory.cpp | 217 ++++++ lib/src/box/BoxDirectory.hpp | 35 + lib/src/box/BoxFile.cpp | 62 ++ lib/src/box/BoxFile.hpp | 22 + lib/src/dropbox/DropboxCloud.hpp | 67 ++ lib/src/dropbox/DropboxDirectory.cpp | 157 ++++ lib/src/dropbox/DropboxDirectory.hpp | 46 ++ lib/src/dropbox/DropboxFile.cpp | 81 ++ lib/src/dropbox/DropboxFile.hpp | 19 + lib/src/gdrive/GDriveCloud.hpp | 70 ++ lib/src/gdrive/GDriveDirectory.cpp | 254 +++++++ lib/src/gdrive/GDriveDirectory.hpp | 38 + lib/src/gdrive/GDriveFile.cpp | 61 ++ lib/src/gdrive/GDriveFile.hpp | 23 + lib/src/nextcloud/NextcloudCloud.hpp | 42 ++ lib/src/onedrive/OneDriveCloud.hpp | 69 ++ lib/src/onedrive/OneDriveDirectory.cpp | 151 ++++ lib/src/onedrive/OneDriveDirectory.hpp | 28 + lib/src/onedrive/OneDriveFile.cpp | 61 ++ lib/src/onedrive/OneDriveFile.hpp | 20 + lib/src/request/Request.cpp | 93 +++ lib/src/request/Request.hpp | 109 +++ lib/src/request/Response.hpp | 153 ++++ lib/src/request/curl/CurlRequest.cpp | 198 +++++ lib/src/request/curl/CurlRequest.hpp | 23 + lib/src/webdav/WebdavCloud.hpp | 48 ++ lib/src/webdav/WebdavDirectory.cpp | 209 ++++++ lib/src/webdav/WebdavDirectory.hpp | 28 + lib/src/webdav/WebdavFile.cpp | 90 +++ lib/src/webdav/WebdavFile.hpp | 22 + src/CMakeLists.txt | 33 - src/example.cpp | 10 - src/include/MyLibrary/example.hpp | 15 - test/BoxCloudTest.cpp | 39 + test/BoxDirectoryTest.cpp | 306 ++++++++ test/BoxFileTest.cpp | 119 +++ test/CMakeLists.txt | 41 +- test/CloudFactoryTest.cpp | 66 ++ test/DropboxCloudTest.cpp | 39 + test/DropboxDirectoryTest.cpp | 317 ++++++++ test/DropboxFileTest.cpp | 115 +++ test/GDriveCloudTest.cpp | 39 + test/GDriveDirectoryTest.cpp | 304 ++++++++ test/GDriveFileTest.cpp | 108 +++ test/NextcloudCloudTest.cpp | 64 ++ test/OAuth2CredentialsTest.cpp | 1 + test/OneDriveCloudTest.cpp | 40 + test/OneDriveDirectoryTest.cpp | 267 +++++++ test/OneDriveFileTest.cpp | 132 ++++ test/UsernamePasswordCredentialsTest.cpp | 1 + test/WebdavCloudTest.cpp | 64 ++ test/WebdavDirectoryTest.cpp | 460 ++++++++++++ test/WebdavFileTest.cpp | 149 ++++ test/macros/access_protected.hpp | 10 + test/macros/request_mock.hpp | 46 ++ test/macros/shared_ptr_mock.hpp | 6 + test/main.cpp | 7 + test/mylibrarytest.cpp | 19 - test_package/CMakeLists.txt | 12 +- test_package/conanfile.py | 4 +- test_package/example.cpp | 8 +- 95 files changed, 7254 insertions(+), 246 deletions(-) create mode 100644 example/CMakeLists.txt create mode 100644 example/main.cpp create mode 100644 lib/CMakeLists.txt create mode 100644 lib/include/CloudSync/Cloud.hpp create mode 100644 lib/include/CloudSync/CloudFactory.hpp create mode 100644 lib/include/CloudSync/Credentials.hpp create mode 100644 lib/include/CloudSync/Directory.hpp create mode 100644 lib/include/CloudSync/Exceptions.hpp create mode 100644 lib/include/CloudSync/File.hpp create mode 100644 lib/include/CloudSync/OAuth2Credentials.hpp create mode 100644 lib/include/CloudSync/Proxy.hpp create mode 100644 lib/include/CloudSync/Resource.hpp create mode 100644 lib/include/CloudSync/UsernamePasswordCredentials.hpp create mode 100644 lib/src/Cloud.cpp create mode 100644 lib/src/CloudFactory.cpp create mode 100644 lib/src/CloudImpl.cpp create mode 100644 lib/src/CloudImpl.hpp create mode 100644 lib/src/Directory.cpp create mode 100644 lib/src/OAuth2Credentials.cpp create mode 100644 lib/src/Proxy.cpp create mode 100644 lib/src/ResourceImpl.hpp create mode 100644 lib/src/UsernamePasswordCredentials.cpp create mode 100644 lib/src/box/BoxCloud.hpp create mode 100644 lib/src/box/BoxDirectory.cpp create mode 100644 lib/src/box/BoxDirectory.hpp create mode 100644 lib/src/box/BoxFile.cpp create mode 100644 lib/src/box/BoxFile.hpp create mode 100644 lib/src/dropbox/DropboxCloud.hpp create mode 100644 lib/src/dropbox/DropboxDirectory.cpp create mode 100644 lib/src/dropbox/DropboxDirectory.hpp create mode 100644 lib/src/dropbox/DropboxFile.cpp create mode 100644 lib/src/dropbox/DropboxFile.hpp create mode 100644 lib/src/gdrive/GDriveCloud.hpp create mode 100644 lib/src/gdrive/GDriveDirectory.cpp create mode 100644 lib/src/gdrive/GDriveDirectory.hpp create mode 100644 lib/src/gdrive/GDriveFile.cpp create mode 100644 lib/src/gdrive/GDriveFile.hpp create mode 100644 lib/src/nextcloud/NextcloudCloud.hpp create mode 100644 lib/src/onedrive/OneDriveCloud.hpp create mode 100644 lib/src/onedrive/OneDriveDirectory.cpp create mode 100644 lib/src/onedrive/OneDriveDirectory.hpp create mode 100644 lib/src/onedrive/OneDriveFile.cpp create mode 100644 lib/src/onedrive/OneDriveFile.hpp create mode 100644 lib/src/request/Request.cpp create mode 100644 lib/src/request/Request.hpp create mode 100644 lib/src/request/Response.hpp create mode 100644 lib/src/request/curl/CurlRequest.cpp create mode 100644 lib/src/request/curl/CurlRequest.hpp create mode 100644 lib/src/webdav/WebdavCloud.hpp create mode 100644 lib/src/webdav/WebdavDirectory.cpp create mode 100644 lib/src/webdav/WebdavDirectory.hpp create mode 100644 lib/src/webdav/WebdavFile.cpp create mode 100644 lib/src/webdav/WebdavFile.hpp delete mode 100644 src/CMakeLists.txt delete mode 100644 src/example.cpp delete mode 100644 src/include/MyLibrary/example.hpp create mode 100644 test/BoxCloudTest.cpp create mode 100644 test/BoxDirectoryTest.cpp create mode 100644 test/BoxFileTest.cpp create mode 100644 test/CloudFactoryTest.cpp create mode 100644 test/DropboxCloudTest.cpp create mode 100644 test/DropboxDirectoryTest.cpp create mode 100644 test/DropboxFileTest.cpp create mode 100644 test/GDriveCloudTest.cpp create mode 100644 test/GDriveDirectoryTest.cpp create mode 100644 test/GDriveFileTest.cpp create mode 100644 test/NextcloudCloudTest.cpp create mode 100644 test/OAuth2CredentialsTest.cpp create mode 100644 test/OneDriveCloudTest.cpp create mode 100644 test/OneDriveDirectoryTest.cpp create mode 100644 test/OneDriveFileTest.cpp create mode 100644 test/UsernamePasswordCredentialsTest.cpp create mode 100644 test/WebdavCloudTest.cpp create mode 100644 test/WebdavDirectoryTest.cpp create mode 100644 test/WebdavFileTest.cpp create mode 100644 test/macros/access_protected.hpp create mode 100644 test/macros/request_mock.hpp create mode 100644 test/macros/shared_ptr_mock.hpp create mode 100644 test/main.cpp delete mode 100644 test/mylibrarytest.cpp diff --git a/.github/workflows/configureBuildTestCreateAndUpload.yaml b/.github/workflows/configureBuildTestCreateAndUpload.yaml index 05ea742..88f463c 100644 --- a/.github/workflows/configureBuildTestCreateAndUpload.yaml +++ b/.github/workflows/configureBuildTestCreateAndUpload.yaml @@ -2,9 +2,11 @@ on: push: branches: - '**' + tags-ignore: + - '**' pull_request: release: - types: [released, prereleased] + types: [released] name: Configure, Build, Test, Create and Upload library @@ -23,21 +25,14 @@ jobs: - name: Install Conan uses: turtlebrowser/get-conan@main - name: Add artifactory remote - run: conan remote add jothepro-conan-public https://jothepro.jfrog.io/artifactory/api/conan/conan-public + run: conan remote add gitlab https://gitlab.com/api/v4/projects/15425736/packages/conan - name: Install dependencies - run: conan install . - - name: configure, build and test project - if: github.event_name != 'release' - run: conan build . - - name: configure, build, test and create project for beta channel - if: github.event.release.prerelease - run: conan create . jothepro/beta - - name: configure, build, test and create project for stable channel - if: ${{ !github.event.release.prerelease }} - run: conan create . jothepro/stable + run: conan install -if build --build missing . + - name: configure, build, test and create project + run: conan create -tbf build . jothepro/release - name: upload to artifactory if: github.event_name == 'release' - run: conan upload mylibrary -r=jothepro-conan-public --all --confirm + run: conan upload libcloudsync -r=gitlab --all --confirm env: CONAN_LOGIN_USERNAME: ${{ secrets.CONAN_LOGIN_USERNAME }} CONAN_PASSWORD: ${{ secrets.CONAN_PASSWORD }} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index c989b68..56b4053 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ include(${CMAKE_BINARY_DIR}/conan_paths.cmake OPTIONAL) get_version(PROJECT_VERSION) -project(MyLibrary +project(CloudSync LANGUAGES CXX VERSION ${PROJECT_VERSION}) @@ -22,7 +22,11 @@ if(DOXYGEN_FOUND) ) endif(DOXYGEN_FOUND) -add_subdirectory(src) +add_subdirectory(lib) + +if(EXISTS example) + add_subdirectory(example) +endif() include(CTest) if(BUILD_TESTING) diff --git a/Doxyfile b/Doxyfile index ba88928..9d7678a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -32,7 +32,7 @@ DOXYFILE_ENCODING = UTF-8 # title of most generated pages and in a few other places. # The default value is: My Project. -PROJECT_NAME = "C++ Library Template" +PROJECT_NAME = "libCloudSync" # The PROJECT_NUMBER tag can be used to enter a project or revision number. This # could be handy for archiving the generated documentation or if some version @@ -44,7 +44,7 @@ PROJECT_NUMBER = # for a project that appears at the top of each page and should give viewer a # quick idea about the purpose of the project. Keep the description short. -PROJECT_BRIEF = "Utilizing CMake and Conan" +PROJECT_BRIEF = "Access Cloud Storage from C++" # With the PROJECT_LOGO tag one can specify a logo or an icon that is included # in the documentation. The maximum height of the logo should not exceed 55 @@ -864,7 +864,7 @@ WARN_LOGFILE = # spaces. See also FILE_PATTERNS and EXTENSION_MAPPING # Note: If this tag is empty the current directory is searched. -INPUT = src/include \ +INPUT = lib/include \ README.md \ docs @@ -2365,7 +2365,7 @@ HIDE_UNDOC_RELATIONS = YES # set to NO # The default value is: NO. -HAVE_DOT = NO +HAVE_DOT = YES # The DOT_NUM_THREADS specifies the number of dot invocations doxygen is allowed # to run in parallel. When set to 0 doxygen will base this on the number of @@ -2547,7 +2547,7 @@ DIRECTORY_GRAPH = YES # The default value is: png. # This tag requires that the tag HAVE_DOT is set to YES. -DOT_IMAGE_FORMAT = png +DOT_IMAGE_FORMAT = svg # If DOT_IMAGE_FORMAT is set to svg, then this option can be set to YES to # enable generation of interactive SVG images that allow zooming and panning. @@ -2638,7 +2638,7 @@ MAX_DOT_GRAPH_DEPTH = 0 # The default value is: NO. # This tag requires that the tag HAVE_DOT is set to YES. -DOT_TRANSPARENT = NO +DOT_TRANSPARENT = YES # Set the DOT_MULTI_TARGETS tag to YES to allow dot to generate multiple output # files in one run (i.e. multiple -o and -T options on the command line). This diff --git a/LICENSE b/LICENSE index 1d8b99a..06fcd04 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,674 @@ -MIT License - -Copyright (c) 2021 jothepro - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + libCloudSync + Copyright (C) 2021 jothepro + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + libCloudSync Copyright (C) 2021 jothepro + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/README.md b/README.md index 22f567f..89ea53b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,20 @@ -# C++ Library Template +# libCloudSync -[![GitHub release (latest by date)](https://img.shields.io/github/v/release/jothepro/cmake-conan-library-template)](https://github.com/jothepro/cmake-conan-library-template/releases/latest) -[![GitHub](https://img.shields.io/github/license/jothepro/cmake-conan-library-template)](https://github.com/jothepro/cmake-conan-library-template/blob/main/LICENSE) +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/jothepro/libcloudsync)](https://github.com/jothepro/libcloudsync/releases/latest) +[![GitHub](https://img.shields.io/github/license/jothepro/libcloudsync)](https://github.com/jothepro/libcloudsync/blob/main/LICENSE) -A basic **C++ library template** utilizing [CMake](https://cmake.org/) and [Conan](https://conan.io/). +A simple to use C++ interface to interact with cloud storage providers. ## Features -- 🎣 Dependency management with **Conan** -- 🍭 Build configuration with **CMake** -- 🧩 Automatic publishing of artifacts to **Artifactory** with Github Actions -- πŸ“‘ Automatic publishing of **Doxygen** documentation with Github Actions -- πŸš€ Preconfigured for Unit-Testing with **Catch2** +- ☁️ Supported Cloud Providers: + - Nextcloud + - Owncloud + - Dropbox + - Box + - Onedrive + - Gdrive + ## Installation @@ -21,18 +24,18 @@ To use this library in you project, you can install it in the following ways: ```sh # Add artifactory repository as remote: conan remote add jothepro-conan-public https://jothepro.jfrog.io/artifactory/api/conan/conan-public -# Install a release of `mylibrary` -conan install --remote jothepro-conan-public mylibrary/0.1.7@jothepro/stable +# Install a release of `libcloudsync` +conan install --remote jothepro-conan-public libcloudsync/0.1.7@jothepro/stable ``` If you don't want to build & run tests when building from source, set the [CONAN_RUN_TESTS](https://docs.conan.io/en/latest/reference/env_vars.html#conan-run-tests) variable: ```sh -install --remote jothepro-conan-public mylibrary/0.1.7@jothepro/stable -e CONAN_RUN_TESTS=0 +install --remote jothepro-conan-public libcloudsync/0.1.7@jothepro/stable -e CONAN_RUN_TESTS=0 ``` Pre-Releases are available in the `beta` channel: ```sh -conan install --remote jothepro-conan-public mylibrary/0.1.8@jothepro/beta +conan install --remote jothepro-conan-public libcloudsync/0.1.8@jothepro/beta ``` @@ -42,31 +45,47 @@ conan install --remote jothepro-conan-public mylibrary/0.1.8@jothepro/beta - Conan >= 1.30 - CMake >= 3.15 -- Doxygen 1.9.1 (optional) +- Doxygen 1.9.2 (optional) ### Build - **Commandline**: ```sh - # Create build folder for out-of-source build - mkdir build && cd build - # Install Dependencies with Conan - conan install .. - # Configure, Build & Test - conan build .. + # install dependencies with Conan + conan install -if build --build missing . + # configure, build & test with Conan + conan build -bf build . + # or configure & build directly with CMake + cmake -S . -B build && cmake --build build + # and execute the tests with ctest + ctest --test-dir build/test ``` - **Clion**: Install the [Conan Plugin](https://plugins.jetbrains.com/plugin/11956-conan) before configuring & building the project as usual. ### Test -This template uses [Catch2](https://github.com/catchorg/Catch2) for testing. The Unit-tests are defined in `test`. +This library uses [Catch2](https://github.com/catchorg/Catch2) for testing. The Unit-tests are defined in `test`. + +- **Commandline**: To run just the unit-tests, you can run `conan build -bf build --test .`. +- **CLion**: Execute the `CloudSyncTest` target + +### Example -- **Commandline**: To run just the unit-tests, you can run `conan build .. --test`. -- **CLion**: Execute the `MyLibraryTest` target +A small CLI example implementation is provided, to show the capabilities of the library. + +```bash +# build the example +cmake --build build --target CloudSyncExample +# execute the program +cd build/example +./CloudSyncExample --help +# usage example: connect to a dropbox app folder +./CloudSyncExample --dropbox --token +``` ### Documentation -This template uses [Doxygen](https://www.doxygen.nl/index.html) for documenation. +This project uses [Doxygen](https://www.doxygen.nl/index.html) for documenation. To generate the docs, run `doxygen Doxyfile` or execute the `doxygen` target defined in the `CMakeLists.txt`. @@ -75,70 +94,41 @@ To generate the docs, run `doxygen Doxyfile` or execute the `doxygen` target def This template uses [Github Actions](https://github.com/features/actions) for automating the release of a new library version. - The workflow `configureBuildTestCreateAndUpload.yaml` configures, builds, tests the library automatically on each push. - When a new release is created in Github, the resulting artifact is automatically uploaded to [a public artifactory repository](https://jothepro.jfrog.io/ui/repos/tree/General/conan-public%2F_%2Fmylibrary) -- The workflow `publish-pages.yaml` automatically builds and publishes the documentation to [Github Pages](https://jothepro.github.io/cpp-library-template/) when a new release is created in Github. + When a new release is created in Github, the resulting artifact is automatically uploaded to [a public artifactory repository](https://jothepro.jfrog.io/ui/repos/tree/General/conan-public%2F_%2Flibcloudsync) +- The workflow `publish-pages.yaml` automatically builds and publishes the documentation to [Github Pages](https://jothepro.github.io/libcloudsync/) when a new release is created in Github. -## Directory Structure -``` -. -β”œβ”€β”€ CMakeLists.txt (1) -β”œβ”€β”€ Doxyfile (2) -β”œβ”€β”€ LICENSE (3) -β”œβ”€β”€ README.md (4) -β”œβ”€β”€ conanfile.py (5) -β”œβ”€β”€ docs (6) -β”‚ β”œβ”€β”€ doxygen-awesome-css (7) -β”‚ β”œβ”€β”€ doxygen-custom (8) -β”‚ β”‚ └── ... -β”‚ β”œβ”€β”€ example-page.dox (9) -β”‚ └── img (10) -β”‚ └── ... -β”œβ”€β”€ src (11) -β”‚ β”œβ”€β”€ CMakeLists.txt (12) -β”‚ β”œβ”€β”€ example.cpp (13) -β”‚ └── include (14) -β”‚ └── MyLibrary (15) -β”‚ └── example.hpp (16) -β”œβ”€β”€ test (17) -β”‚ β”œβ”€β”€ CMakeLists.txt (18) -β”‚ └── mylibrarytest.cpp (19) -└── test_package (20) - β”œβ”€β”€ CMakeLists.txt (21) - β”œβ”€β”€ conanfile.py (22) - └── example.cpp (23) +## Useful Links -``` +### API Documentation -1. Root `CMakeLists.txt`. Includes Library Sources (11) and unit tests (18). -2. Doxyfile for documentation generation. `CMakeLists.txt` (1) defines a target `doxygen` to build the documentation. -3. License file. -4. The Readme you currently read. -5. Conanfile. Used to install dependencies & publishing the package. -6. Documentation subdirectory. Generated docs will pe placed under `docs/html`. -7. Submodule containing the custom-css for doxygen. -8. Project-specific doxygen customizations. -9. Example documentation file. All `.dox` files in this dir will be automatically included in the documentation. -10. Images for documentation. -11. Library sources folder. -12. `CMakeLists.txt` for library. -13. Private source file. -14. Public headers folder. -15. Library namespace. -16. Public header file example. -17. Unit tests folder. -18. `CMakeLists.txt` that defines unit tests. -19. Example unit test file. -20. Conan linking test directory. -21. CMakeLists.txt that defines an example project that links the library. -22. Conanfile that defines linking test. -23. Example sources that require the library to build & run successfully. - - -## Credits - -This template is inspired by these talks: - -- [C++Now 2017: Daniel Pfeifer β€œEffective CMake"](https://www.youtube.com/watch?v=bsXLMQ6WgIk) - -- [CppCon 2018: Mateusz Pusz β€œGit, CMake, Conan - How to ship and reuse our C++ projects”](https://www.youtube.com/watch?v=S4QSKLXdTtA) \ No newline at end of file +- [Box](https://developer.box.com/reference/) +- [Dropbox](https://www.dropbox.com/developers/documentation/http/documentation) +- [OneDrive](https://docs.microsoft.com/en-us/onedrive/developer/rest-api/) +- [GDrive](https://developers.google.com/drive/api/v3/reference) +- [Nextcloud](https://docs.nextcloud.com/server/18/developer_manual/client_apis/WebDAV/index.html) + +### Getting developer tokens + +#### Box + +Go to [`https://app.box.com/developers/console`](https://app.box.com/developers/console) and create a new app. In the left-navigation click on `Configuration`. You can find a button there that will create a developer-token for you. The generated token will be valid for 60 minutes. + +#### Dropbox + +Visit [`https://www.dropbox.com/developers/apps`](https://www.dropbox.com/developers/apps) and create a new app. In the tab `Settings` under the section `OAuth 2` there is a button to create an access token. It seems to be valid infinitely. + +#### OneDrive + +Create an app in the [Azure Portal](https://portal.azure.com/). Copy the client-Id from the 'General'-Section. +Follow [these](https://docs.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/graph-oauth?view=odsp-graph-online) instructions to obtain a token. + +#### GDrive + +Go to `https://developers.google.com/oauthplayground`, select the required scope, e.g. `https://www.googleapis.com/auth/drive.appdata` and let the tool do its job. + +#### Nextcloud + +Does not support OAuth2, requires Username/Password login. + +In your Nextcloud installation go to `/settings/user/security` to create a specific app-password (recommended). \ No newline at end of file diff --git a/cmake/modules/GetVersion.cmake b/cmake/modules/GetVersion.cmake index 2c5fb0d..395dfec 100644 --- a/cmake/modules/GetVersion.cmake +++ b/cmake/modules/GetVersion.cmake @@ -15,11 +15,11 @@ function(get_version OUTPUT) string(STRIP ${GIT_DESCRIBE_OUTPUT} VERSION_STRING) else() message(SEND_ERROR "${MESSAGE_ERROR_PREFIX} an error occurred when executing `git describe`") - set(VERSION_STRING "0.0.0") + set(VERSION_STRING "v0.0.0") endif() else() message(SEND_ERROR "${MESSAGE_ERROR_PREFIX} git is not available") - set(VERSION_STRING "0.0.0") + set(VERSION_STRING "v0.0.0") endif() endif() diff --git a/conanfile.py b/conanfile.py index 7a06caa..fb0df51 100644 --- a/conanfile.py +++ b/conanfile.py @@ -10,15 +10,15 @@ def get_version(): return version[1:] -class MyLibraryConan(ConanFile): - name = "mylibrary" +class LibCloudSyncConan(ConanFile): + name = "libcloudsync" version = get_version() - description = """A basic C++ library project template using cmake and conan.""" + description = """A simple to use C++ interface to interact with cloud storage providers.""" settings = "os", "compiler", "build_type", "arch" license = "AGPL-3.0-or-later" generators = "cmake_find_package", "cmake_paths" exports = "VERSION" - exports_sources = "src/*", "test/*", "cmake/*", "VERSION", "LICENSE", "CMakeLists.txt" + exports_sources = "lib/*", "test/*", "cmake/*", "VERSION", "LICENSE", "CMakeLists.txt" author = "jothepro" options = { "shared": [True, False], @@ -26,13 +26,26 @@ class MyLibraryConan(ConanFile): } default_options = { "shared": False, - "fPIC": True + "fPIC": True, + "fakeit:integration": "catch", + "libcurl:with_ftp": False, + "libcurl:with_imap": False, + "libcurl:with_mqtt": False, + "libcurl:with_pop3": False, + "libcurl:with_rtsp": False, + "libcurl:with_smb": False, + "libcurl:with_smtp": False, + "libcurl:with_tftp": False, } requires = ( - "nlohmann_json/3.9.1" + "nlohmann_json/3.9.1", + "pugixml/1.11", + "libcurl/7.79.1", + "cxxopts/2.2.1" ) build_requires = ( - "catch2/2.13.4" + "catch2/2.13.4", + "fakeit/2.0.7" ) def build(self): @@ -46,6 +59,6 @@ def build(self): cmake.install() def package_info(self): - self.cpp_info.names["cmake_find_package"] = "MyLibrary" - self.cpp_info.names["cmake_find_package_multi"] = "MyLibrary" - self.cpp_info.libs = ["MyLibrary"] \ No newline at end of file + self.cpp_info.names["cmake_find_package"] = "CloudSync" + self.cpp_info.names["cmake_find_package_multi"] = "CloudSync" + self.cpp_info.libs = ["CloudSync"] \ No newline at end of file diff --git a/docs/doxygen-awesome-css b/docs/doxygen-awesome-css index 37037b0..b6a3373 160000 --- a/docs/doxygen-awesome-css +++ b/docs/doxygen-awesome-css @@ -1 +1 @@ -Subproject commit 37037b08bea9b72235105816d28c4883684c4eda +Subproject commit b6a337311ec3bbc5603fa2ce541850a603844e58 diff --git a/docs/doxygen-custom/custom.css b/docs/doxygen-custom/custom.css index a346095..3ca06b8 100644 --- a/docs/doxygen-custom/custom.css +++ b/docs/doxygen-custom/custom.css @@ -1,5 +1,5 @@ :root { - --side-nav-fixed-width: 370px; + --side-nav-fixed-width: 300px; } .github-corner svg { diff --git a/docs/doxygen-custom/header.html b/docs/doxygen-custom/header.html index af4252c..269bf50 100644 --- a/docs/doxygen-custom/header.html +++ b/docs/doxygen-custom/header.html @@ -8,14 +8,14 @@ - - - + + + - - + + $projectname: $title @@ -33,7 +33,7 @@ - + diff --git a/docs/img/logo.drawio.svg b/docs/img/logo.drawio.svg index 47e9b4f..1536cde 100644 --- a/docs/img/logo.drawio.svg +++ b/docs/img/logo.drawio.svg @@ -1 +1 @@ -



?
?
+
+
+
+
Viewer does not support full SVG 1.1
\ No newline at end of file + \ No newline at end of file diff --git a/example/CMakeLists.txt b/example/CMakeLists.txt new file mode 100644 index 0000000..71fe6b3 --- /dev/null +++ b/example/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.15) + +project(CloudSyncExample) + +find_package(cxxopts REQUIRED) + +add_executable(CloudSyncExample main.cpp) + +target_link_libraries(CloudSyncExample + PRIVATE + CloudSync::CloudSync + cxxopts::cxxopts +) + +set_target_properties(CloudSyncExample + PROPERTIES + EXCLUDE_FROM_ALL true +) \ No newline at end of file diff --git a/example/main.cpp b/example/main.cpp new file mode 100644 index 0000000..c211e6f --- /dev/null +++ b/example/main.cpp @@ -0,0 +1,210 @@ +#include "CloudSync/CloudFactory.hpp" +#include "CloudSync/Directory.hpp" +#include "CloudSync/File.hpp" +#include "CloudSync/OAuth2Credentials.hpp" +#include "CloudSync/UsernamePasswordCredentials.hpp" +#include +#include +#include +#include + +int main(int argc, char *argv[]) { + std::string providerUrl; + std::shared_ptr cloud; + std::shared_ptr dir; + std::string root; + + cxxopts::Options options("cloudsync", "A small commandline utility to access any cloud with libCloudSync"); + options.add_options() + ("h,help", "Print help"); + options.add_options("1) cloud providers") + ("webdav", "access webdav server") + ("nextcloud", "access nextcloud server") + ("owncloud", "access owncloud server") + ("dropbox", "access dropbox cloud") + ("box", "access box cloud") + ("onedrive", "access onedrive server") + ("gdrive", "access google drive"); + options.add_options("2) configuration") + ("r,root", "root name (used in onedrive & gdrive)", cxxopts::value()) + ("d,domain", "address under which the cloud can be found", cxxopts::value()); + options.add_options("3) authentication") + ("u,username", "username, if login with username & password should be used. You will be prompted to provide the related password.", cxxopts::value()) + ("t,token", "OAuth2 access token. If not set, you will be prompted to provide it.", cxxopts::value()); + auto result = options.parse(argc, argv); + + // printing help + if (result.count("help")) { + std::cout << options.help() << std::endl; + exit(0); + } + + // get server address + if (result.count("domain")) { + providerUrl = result["domain"].as(); + } else { + if(result.count("webdav") || result.count("nextcloud") || result.count("owncloud")) { + std::cerr << "no domain provided" << std::endl; + std::cout << options.help({"2) configuration"}) << std::endl; + exit(1); + } + } + + if (result.count("root")) { + root = result["root"].as(); + } + + // find out cloud type + if (result.count("webdav")) { + cloud = CloudSync::CloudFactory().webdav(providerUrl); + } else if (result.count("nextcloud")) { + cloud = CloudSync::CloudFactory().nextcloud(providerUrl); + } else if (result.count("owncloud")) { + // TODO + } else if (result.count("dropbox")) { + cloud = CloudSync::CloudFactory().dropbox(); + } else if (result.count("box")) { + cloud = CloudSync::CloudFactory().box(); + } else if (result.count("onedrive")) { + cloud = CloudSync::CloudFactory().onedrive(root); + } else if (result.count("gdrive")) { + cloud = CloudSync::CloudFactory().gdrive(root); + } else { + std::cerr << "no cloud provider specified" << std::endl; + std::cout << options.help({"1) cloud providers"}) << std::endl; + exit(1); + } + + // find out login method and do login + if (result.count("username")) { + while (true) { + try { + std::string password; + std::cout << "password: "; + std::cin >> password; + auto credentials = + CloudSync::UsernamePasswordCredentials(result["username"].as(), password); + cloud->login(credentials); + break; + } catch (CloudSync::BaseException& e) { + std::cerr << "\e[1A" << e.what() << std::endl; + } + } + + } else { + if (result.count("token")) { + auto credentials = CloudSync::OAuth2Credentials(result["token"].as()); + cloud->login(credentials); + } else { + while (true) { + try { + std::string token; + std::cout << "token: "; + std::cin >> token; + auto credentials = CloudSync::OAuth2Credentials(token); + cloud->login(credentials); + break; + } catch (CloudSync::BaseException& e) { + std::cerr << "\e[1A" << e.what() << std::endl; + } + } + } + } + + std::cout << "\e[1A" << std::setfill(' ') << std::left << std::setw(80) << cloud << std::endl; + std::cout << "Logged in as: " << cloud->getUserDisplayName() << std::endl; + dir = cloud->root(); + + std::cout << dir << std::endl; + + // cli loop waiting for commands + while (true) { + try { + std::cout << dir->path << "> "; + std::string action; + std::cin >> action; + + if (action == "ls") { + auto list = dir->ls(); + std::cout << "total: " << list.size() << std::endl; + for (const auto& res : list) { + std::cout << res << std::endl; + } + } else if (action == "cd") { + std::string path; + std::cin >> path; + dir = dir->cd(path); + std::cout << dir << std::endl; + } else if (action == "pwd") { + std::cout << dir->pwd() << std::endl; + } else if (action == "file") { + std::string filename; + std::cin >> filename; + auto file = dir->file(filename); + std::cout << file << std::endl; + } else if (action == "read") { + std::string filename; + std::cin >> filename; + auto file = dir->file(filename); + std::cout << file->read() << std::endl; + } else if (action == "mkdir") { + std::string dirname; + std::cin >> dirname; + dir->mkdir(dirname); + } else if (action == "rmdir") { + std::string dirname; + std::cin >> dirname; + auto rmdir = dir->cd(dirname); + std::cout << "Are you sure you want to delete the folder '" << rmdir->pwd() << "'? (y/n) "; + std::string confirmation; + std::cin >> confirmation; + if (confirmation == "y") { + rmdir->rmdir(); + } + } else if (action == "touch") { + std::string filename; + std::cin >> filename; + dir->touch(filename); + } else if (action == "rm") { + std::string filename; + std::cin >> filename; + auto file = dir->file(filename); + std::cout << "Are you sure you want to delete the file '" << file->path << "'? (y/n) "; + std::string confirmation; + std::cin >> confirmation; + if (confirmation == "y") { + file->rm(); + } + } else if (action == "write") { + std::string filename; + std::cin >> filename; + auto file = dir->file(filename); + std::cout << "Write file content. To input multiline text, add \" at the beginning and end of the input.\n" + "This will override the current file content!!!!!" + << std::endl; + std::string inputline; + std::string filecontent; + while (std::cin >> inputline) { + filecontent += inputline + "\n"; + if(filecontent[0] == '"') { + if(filecontent.size() > 1 && filecontent[filecontent.size() - 2] == '"') { + filecontent = filecontent.substr(1, filecontent.size() - 3); + filecontent += "\n"; + break; + } + } else { + break; + } + } + file->write(filecontent); + } else if (action == "exit") { + exit(0); + } else { + std::cout << "unknown command. available commands: ls, cd , pwd, file , read , write , mkdir , rmdir , touch , rm , exit" << std::endl; + } + } catch (CloudSync::BaseException& e) { + std::cout << "ERROR: " << e.what() << std::endl; + } + } + return 0; +} \ No newline at end of file diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt new file mode 100644 index 0000000..31c62f0 --- /dev/null +++ b/lib/CMakeLists.txt @@ -0,0 +1,141 @@ +cmake_minimum_required(VERSION 3.15) + +project(CloudSync) + +# dependencies +find_package(nlohmann_json REQUIRED) +find_package(pugixml REQUIRED) +find_package(CURL REQUIRED) + +set(INCLUDE + include/CloudSync/CloudFactory.hpp + include/CloudSync/Cloud.hpp + include/CloudSync/Directory.hpp + include/CloudSync/Exceptions.hpp + include/CloudSync/File.hpp + include/CloudSync/Resource.hpp + include/CloudSync/Credentials.hpp + include/CloudSync/OAuth2Credentials.hpp + include/CloudSync/UsernamePasswordCredentials.hpp + include/CloudSync/Proxy.hpp +) + +set(SRC + src/CloudFactory.cpp + src/Cloud.cpp + src/CloudImpl.hpp + src/CloudImpl.cpp + src/OAuth2Credentials.cpp + src/UsernamePasswordCredentials.cpp + src/Proxy.cpp + src/Directory.cpp +) + +set(SRC_WEBDAV + src/webdav/WebdavCloud.hpp + src/webdav/WebdavDirectory.hpp + src/webdav/WebdavDirectory.cpp + src/webdav/WebdavFile.hpp + src/webdav/WebdavFile.cpp +) + +set(SRC_NEXTCLOUD + src/nextcloud/NextcloudCloud.hpp +) + +set(SRC_DROPBOX + src/dropbox/DropboxCloud.hpp + src/dropbox/DropboxDirectory.hpp + src/dropbox/DropboxDirectory.cpp + src/dropbox/DropboxFile.hpp + src/dropbox/DropboxFile.cpp +) + +set(SRC_BOX + src/box/BoxCloud.hpp + src/box/BoxDirectory.hpp + src/box/BoxDirectory.cpp + src/box/BoxFile.hpp + src/box/BoxFile.cpp +) + +set(SRC_ONEDRIVE + src/onedrive/OneDriveCloud.hpp + src/onedrive/OneDriveDirectory.hpp + src/onedrive/OneDriveDirectory.cpp + src/onedrive/OneDriveFile.hpp + src/onedrive/OneDriveFile.cpp +) + +set(SRC_GDRIVE + src/gdrive/GDriveCloud.hpp + src/gdrive/GDriveDirectory.hpp + src/gdrive/GDriveDirectory.cpp + src/gdrive/GDriveFile.hpp + src/gdrive/GDriveFile.cpp +) + +set(SRC_REQUEST + src/request/Request.hpp + src/request/Response.hpp + src/request/Request.cpp +) + +set(SRC_REQUEST_CURL + src/request/curl/CurlRequest.hpp + src/request/curl/CurlRequest.cpp +) +source_group(request\\curl FILES ${SRC_REQUEST_CURL}) +source_group(include\\CloudSync FILES ${INCLUDE}) +source_group(src\\ FILES ${SRC}) +source_group(src\\webdav FILES ${SRC_WEBDAV}) +source_group(src\\nextcloud FILES ${SRC_NEXTCLOUD}) +source_group(src\\dropbox FILES ${SRC_DROPBOX}) +source_group(src\\box FILES ${SRC_BOX}) +source_group(src\\onedrive FILES ${SRC_ONEDRIVE}) +source_group(src\\gdrive FILES ${SRC_GDRIVE}) +source_group(src\\request FILES ${SRC_REQUEST}) +source_group(src\\request\\curl FILES ${SRC_REQUEST_CURL}) + +# library definition +add_library(CloudSync + ${INCLUDE} + ${SRC} + ${SRC_WEBDAV} + ${SRC_NEXTCLOUD} + ${SRC_DROPBOX} + ${SRC_BOX} + ${SRC_ONEDRIVE} + ${SRC_GDRIVE} + ${SRC_REQUEST} + ${SRC_REQUEST_CURL} +) +target_compile_features(CloudSync PUBLIC cxx_std_17) +target_include_directories(CloudSync + PUBLIC + include + src +) + +target_link_libraries(CloudSync + PUBLIC + nlohmann_json::nlohmann_json + pugixml::pugixml + CURL::CURL +) + +set_target_properties (CloudSync PROPERTIES + FOLDER CloudSync +) + +# library installation +install(TARGETS CloudSync EXPORT CloudSyncTargets + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + RUNTIME DESTINATION bin + INCLUDES DESTINATION include) + +install(DIRECTORY include/CloudSync + DESTINATION include) + +add_library(CloudSync::CloudSync ALIAS CloudSync) diff --git a/lib/include/CloudSync/Cloud.hpp b/lib/include/CloudSync/Cloud.hpp new file mode 100644 index 0000000..eb1849f --- /dev/null +++ b/lib/include/CloudSync/Cloud.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include "Credentials.hpp" +#include "Directory.hpp" +#include "Exceptions.hpp" +#include "Proxy.hpp" +#include + +namespace CloudSync { + +class Cloud { + public: + // MARK: - errors + + /** + * @brief Thrown if a provided interface/functionality is not implemented by the used cloud provider. + * + * If this error is being thrown it means the API has been used in a wrong way. + */ + class MethodNotSupportedError : public std::logic_error { + public: + MethodNotSupportedError(const std::string &what = "") + : std::logic_error("This method is not supported by this cloud provider. " + what){}; + }; + + /// Base Exception for all Cloud-related errors. + class CloudException : public BaseException { + public: + CloudException(const std::string &what) : BaseException(what){}; + }; + + /** + * @brief Thrown if authorization to the cloud has failed. + * + * @warning Be aware that this may be thrown at any time, not just on the `login()` call. For example the password + * could be changed or the OAuth2 token could be revoked at any point in time. Recovering from this is only possible + * by asking the user for new credentials. + */ + class AuthorizationFailed : public CloudException { + public: + AuthorizationFailed() : CloudException("Login to the cloud failed."){}; + }; + + /** + * @brief Thrown if the communication to with the server failed. + * + * In an ideal world this would never be thrown. Catching this is an indicator that you found a bug in the library + * or, less likely, in the providers API. It's most likely not possible to recover from this. + * + * Possible reasons why this occurs: + * * A request has failed before it even reached the server. This may be due to a wrong usage of the request + * library or, less likely, a problem with the request library itself (libCURL). + * * The server responded with an unexpected result. Unexpected means that the library does not know how to + * handle it because the implementation or the response does not follow the API-spec. + */ + class CommunicationError : public CloudException { + public: + CommunicationError(const std::string &what = "") + : CloudException("The communication with the server has failed. " + what){}; + }; + + /** + @brief Thrown if the server response cannot be parsed. + * + * This is a special case of a CommunicationError. When using the library in production catching CommunicationErrors + should be enough. + * This exists to make debugging easier. + * + * It's not possible to recover from this. + * + */ + class InvalidResponse : public CommunicationError { + public: + InvalidResponse(const std::string &what = "") : CommunicationError("Invalid Response: " + what){}; + }; + + // checks if the cloud storage is available + // MARK: - interface + /** + * Use this to check if the connection to the cloud is working. This currently is just a shorthand for + * `cloud->root()->ls();`. + * @throws AuthorizationFailed if your login-credentials are wrong. + */ + virtual void ping() const = 0; + /** + * set login credentials + * @param credentials either UsernamePasswordCredentials or OAuth2Credentials + */ + virtual std::shared_ptr login(const Credentials &credentials) = 0; + + /** + * @warning This is only functional if **login** was done with OAuth2Credentials and the providers OAuth2 + * implementation uses a refresh-token. Otherwise it will just return an empty string. + * @return the current refresh token, as the OAuth2 spec allows it to change over time. Use this to save your + * current refresh token right before the cloud instance gets destroyed. You can then use it to start a new session + * at a later time. + */ + virtual std::string getCurrentRefreshToken() const = 0; + + virtual std::shared_ptr proxy(const Proxy &proxy) = 0; + /** + * @throws MethodNotSupportedError if no authorize-URL can be provided. + * @return the URL that can be used to authorize at the cloud. Not supported by all providers: + * * **WebDav**: Not supported + * * **Nextcloud**: URL to Nextclouds + * [Login-Flow](https://docs.nextcloud.com/server/18/developer_manual/client_apis/LoginFlow/index.html) endpoint. + * * **Dropbox, Box, Onedrive**: Url to the OAuth2 `/authorize` endpoint + */ + virtual std::string getAuthorizeUrl() const = 0; + /** + * @return the URL to the OAuth2 `/token` endpoint if the cloud supports OAuth2-Login. Otherwise just returns an + * empty string. + */ + virtual std::string getTokenUrl() const = 0; + + virtual std::string getBaseUrl() const = 0; + + /** + * Fetches the users display name (!= username, usually its first- & lastname) + * @warning this makes a network call every time it is called. + * @return the users display name. + */ + virtual std::string getUserDisplayName() const = 0; + /** + * @return root directory. This is the entrypoint for all file operations. + * @code + * auto file = cloud->root()->file("path/to/file.txt"); + * @endcode + */ + virtual std::shared_ptr root() const = 0; + + friend std::ostream &operator<<(std::ostream &output, std::shared_ptr cloud); +}; + +} // namespace CloudSync diff --git a/lib/include/CloudSync/CloudFactory.hpp b/lib/include/CloudSync/CloudFactory.hpp new file mode 100644 index 0000000..206897d --- /dev/null +++ b/lib/include/CloudSync/CloudFactory.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include "Cloud.hpp" + +namespace CloudSync { + +class CloudFactory { + public: + CloudFactory(); + + /** + * Creates a webdav cloud instance. + * @code + * auto cloud = CloudFactory().webdav("https://nextcloud.webo.hosting/remote.php/webdav"); + * @endcode + * @param url address of the webdav endpoint + * @param request custom Request interface implementation to be used for networking + */ + std::shared_ptr webdav(const std::string &url); + + /** + * Creates a nextcloud cloud instance. + * @see https://nextcloud.com + * @code + * auto cloud = CloudFactory().nextcloud("https://nextcloud.webo.hosting"); + * @endcode + * @param url address of the nextcloud server. + * @param request custom Request interface implementation to be used for networking + * @warning do **not** include the path to the webdav endpoint (`/remote.php/webdav`) in the address. The library + * will handle this detail for you! + */ + std::shared_ptr nextcloud(const std::string &url); + + /** + * Create a dropbox cloud instance. + * @see https://www.dropbox.com + * @code + * auto cloud = CloudFactory().dropbox(); + * @endcode + * @param request custom Request interface implementation to be used for networking + */ + std::shared_ptr dropbox(); + + /** + * Create a box cloud instance. + * @see https://www.box.com + * @code + * auto cloud = CloudFactory().box(); + * @endcode + * @param request custom Request interface implementation to be used for networking + */ + std::shared_ptr box(); + + /** + * Creates a OneDrive cloud instance + * @see https://onedrive.live.com + * @see https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/drive_get?view=odsp-graph-online + * @param drive specifies the root of the drive (without leading & trailing slash). Example values: + * * `me/drive/root` + * * `me/drive/special/approot` + * * `groups/{groupId}/drive/root` + * * `sites/{siteId}/drive/root` + * * `drives/{drive-id}/root` + * @param request custom Request interface implementation to be used for networking + */ + std::shared_ptr onedrive(const std::string &drive = "me/drive/root"); + + /** + * Creates a Google Drive cloud instance + * @see https://drive.google.com/ + * @note Google Drive supports app-data folders. But like with OneDrive you need to explicitly tell the API that you + * want to access the app-folder. If you set `rootName` to `root` but the user only has permission for the scope + * `drive.appdata`, the root directory will be empty without write permissions. + * @param rootName the type of root. Possible values: `root`, `appDataFolder`. + * @param request custom Request interface implementation to be used for networking + */ + std::shared_ptr gdrive(const std::string &rootName = "root"); + + virtual ~CloudFactory() = default; + + private: + std::shared_ptr requestImplementation; + std::shared_ptr getRequestImplementation(); +}; + +} // namespace CloudSync diff --git a/lib/include/CloudSync/Credentials.hpp b/lib/include/CloudSync/Credentials.hpp new file mode 100644 index 0000000..b0b4d3b --- /dev/null +++ b/lib/include/CloudSync/Credentials.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace CloudSync { +namespace request { +class Request; +} +class CloudImpl; + +class Credentials { + friend class CloudImpl; + + public: + virtual ~Credentials(){}; + + protected: + /** + * @exception CloudSync::Credentials::InvalidCredentials + * @exception CloudSync::Cloud::CommunicationError + */ + virtual void apply(const std::shared_ptr &request) const {}; +}; + +} // namespace CloudSync diff --git a/lib/include/CloudSync/Directory.hpp b/lib/include/CloudSync/Directory.hpp new file mode 100644 index 0000000..ee1f7f0 --- /dev/null +++ b/lib/include/CloudSync/Directory.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include "File.hpp" +#include "Resource.hpp" +#include +#include +#include +#include + +namespace CloudSync { +/** + * Provider-independend representation of a directory. Can be seen as a pointer to the actual folder in the cloud. This + * does not hold the contents of the folder in memory but makes a network call each time you ask for a resource. + */ +class Directory : public Resource { + public: + /** + * list the current directories content. + * @warning Don't use this to just check if a single file or folder exists! Use `cd()` or `file()` for that and + * catch the Resource::NoSuchFileOrDirectory exception. + * @return a list of resources (Files & Folders) that are contained in the folder. + */ + virtual std::vector> ls() const = 0; + /** + * change directory + * @note The provided path will **always** be handled as relative path. Leading slashes will be ignored. If you need + * to pass an absolute path you should do this: + * @code + * cloud->root()->cd("some/absolute/path"); + * @endcode + * @param path relative path to any other folder. Example values: + * * `foldername` + * * `path/to/other/folder` + * * `..` change to upper directory + * * `subfolder/..` returns the current folder + * * `/foldername/` trailing & leading slashes are ignored. Same as: `foldername` + * @return the desired directory + */ + virtual std::shared_ptr cd(const std::string &path) const = 0; + /** + * remove folder. + * + * @bug If the folder cannot be removed because it still contains resources, this fails with an undefined behaviour. + * It may for example throw a Cloud::CommunicationError. + * [Help me to improve this](https://gitlab.com/jothepro/libcloudsync) + */ + virtual void rmdir() const = 0; + + /** + * print working directory. alias for `folder->path`. + * @return the absolute path of the folder without trailing slash. + */ + std::string pwd() const; + /** + * create a new directory + * @param path to the new folder. Does not create intermediate directories. + * @return the newly created folder + */ + virtual std::shared_ptr mkdir(const std::string &path) const = 0; + /** + * create a new file + * @param path to the new file. + * @return the newly created file + */ + virtual std::shared_ptr touch(const std::string &path) const = 0; + /** + * get file from path + * @param path to the file. The file must already exist. + * @return the requested file + */ + virtual std::shared_ptr file(const std::string &path) const = 0; + + bool isFile() override { + return false; + }; + + protected: + virtual std::string describe() const override { + std::ostringstream output; + output << std::setw(10) << std::left << "d" + << " " << this->name; + return output.str(); + } + Directory( + const std::string &baseUrl, const std::string &dir, const std::shared_ptr &request, + const std::string &name); +}; +} // namespace CloudSync diff --git a/lib/include/CloudSync/Exceptions.hpp b/lib/include/CloudSync/Exceptions.hpp new file mode 100644 index 0000000..00d5641 --- /dev/null +++ b/lib/include/CloudSync/Exceptions.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace CloudSync { +/** + * This is the super-exception for all logical cloud exceptions. + * + * If anything goes wrong while accessing the cloud & its resources, an + * exception inherited from this one will be thrown. + */ +class BaseException : public std::runtime_error { + public: + BaseException(const std::string &what) : std::runtime_error(what){}; +}; +} // namespace CloudSync diff --git a/lib/include/CloudSync/File.hpp b/lib/include/CloudSync/File.hpp new file mode 100644 index 0000000..d0d13dd --- /dev/null +++ b/lib/include/CloudSync/File.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "Resource.hpp" +#include +#include + +namespace CloudSync { +namespace request { +class Request; +} +class File : public Resource { + public: + /** + * deletes the current file. + * @warning Stop using the File object after calling `rm()`. You will just get Resource::NoSuchFileOrDirectory + * exceptions anyway. + * @note Deleting a file doesn't mean its gone. Most clouds know the concept of a Recycle Bin and will move deleted + * resources there. + */ + virtual void rm() = 0; + /** + * @note a revision is what would be called [ETag](https://en.wikipedia.org/wiki/HTTP_ETag) in the HTTP-Protocol. + * It's a unique identifier for the current file version that changes with every change of the file. + * @return the revision/etag of the file. + */ + std::string revision() const { + return this->_revision; + } + /** + * @warning Don't be fooled by the return type of `std::string`. This may also be binary data and it is up to you to + * handle the file content correctly. Some providers also provide mimetypes for files. That information cannot be + * exposed in this API as mimetypes are not supported by all providers. + * @return the file content as a string. + */ + virtual std::string read() const = 0; + /** + * @throws Resource::ResourceHasChanged if the file has changed on the server. Check for a new file version with + * `pollChange()` to resolve this exception. + * @param content New content that should be written to the file. Overrides the existing file content. This may also + * be binary data. + */ + virtual void write(const std::string &content) = 0; + /** + * Checks for a new file version. If a new version exists, the revision of the file will be updated. + * @param longPoll wether or not to do a longPoll. + * @warning Some providers (Dropbox) support long-polling to give instant feedback when a file is updated on the + * server. Be aware that long-polling is a blocking operation and will only return once a new change is available or + * the request has timed out. + * @throws Cloud::MethodNotSupportedError if long-polling is not supported by the provider but `longPoll=true`. + * @return `true` if a new version exists, otherwise `false` + */ + virtual bool pollChange(bool longPoll = false) = 0; + /** + * @return `true` if long-polling is supported, otherwise `false` + * @code + * // Example usage: Only longpoll if it's supported + * file->pollChange(file->supportsLongPoll()); + * @endcode + */ + virtual bool supportsLongPoll() const = 0; + + bool isFile() override { + return true; + }; + + protected: + File( + const std::string &baseUrl, const std::string &dir, const std::shared_ptr &request, + const std::string &name, const std::string &revision) + : Resource(baseUrl, dir, request, name), _revision(revision){}; + virtual std::string describe() const override { + std::ostringstream output; + std::string str = this->revision(); + output << std::setw(10) << std::left << (str.size() > 10 ? str.substr(0, 10) : str) << " " << this->name; + return output.str(); + } + std::string _revision; +}; +} // namespace CloudSync diff --git a/lib/include/CloudSync/OAuth2Credentials.hpp b/lib/include/CloudSync/OAuth2Credentials.hpp new file mode 100644 index 0000000..777a464 --- /dev/null +++ b/lib/include/CloudSync/OAuth2Credentials.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "Cloud.hpp" +#include "Credentials.hpp" +#include +#include +#include + +namespace CloudSync { +namespace request { +class Response; +} +class OAuth2Credentials : public Credentials { + friend class Cloud; + + public: + OAuth2Credentials( + const std::string &accessToken, const std::string &refreshToken = "", + std::chrono::seconds expiresIn = std::chrono::seconds(0)) + : accessToken(accessToken), refreshToken(refreshToken), + expires( + expiresIn != std::chrono::seconds(0) ? std::chrono::system_clock::now() + expiresIn + : std::chrono::system_clock::time_point(std::chrono::seconds(0))){}; + + protected: + void apply(const std::shared_ptr &request) const override; + + private: + const std::string accessToken; + const std::string refreshToken; + const std::chrono::system_clock::time_point expires; +}; +} // namespace CloudSync diff --git a/lib/include/CloudSync/Proxy.hpp b/lib/include/CloudSync/Proxy.hpp new file mode 100644 index 0000000..d3e3cf9 --- /dev/null +++ b/lib/include/CloudSync/Proxy.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +class CloudImpl; + +namespace CloudSync { +namespace request { +class Request; +} +class Proxy { + friend class CloudImpl; + + public: + const static Proxy NOPROXY; + Proxy(const std::string &url, const std::string &username = "", const std::string &password = "") + : url(url), username(username), password(password){}; + + protected: + virtual void apply(const std::shared_ptr &request) const; + + private: + const std::string url; + const std::string username; + const std::string password; +}; +} // namespace CloudSync diff --git a/lib/include/CloudSync/Resource.hpp b/lib/include/CloudSync/Resource.hpp new file mode 100644 index 0000000..09e9b1f --- /dev/null +++ b/lib/include/CloudSync/Resource.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "Exceptions.hpp" +#include +#include + +namespace CloudSync { +namespace request { +class Request; +} +class Resource { + public: + class ResourceException : public BaseException { + public: + ResourceException(const std::string &what) : BaseException(what){}; + }; + + class NoSuchFileOrDirectory : public ResourceException { + public: + NoSuchFileOrDirectory(const std::string &path) : ResourceException("No such file or directory: " + path){}; + }; + + class PermissionDenied : public ResourceException { + public: + PermissionDenied(const std::string &path) + : ResourceException("Forbidden action on file or directory: " + path){}; + }; + + class ResourceHasChanged : public ResourceException { + public: + ResourceHasChanged(const std::string &path) : ResourceException("Resource has changed: " + path){}; + }; + + virtual ~Resource(){}; + const std::string name; + const std::string path; + + /** + * Wether the Resource is a file or a directory. + * @return true if resource is a file, false if it is a directory. + * @note Use this if you need to cast a resource to it's special type to do perform some type-specific action on it. + * A resource can not be of any other type than `File` or `Folder`, so you can safely determine it's type with + * just this method. + */ + virtual bool isFile() = 0; + + friend std::ostream &operator<<(std::ostream &output, const Resource *resource) { + output << resource->describe(); + return output; + } + + protected: + Resource( + const std::string &baseUrl, const std::string &workingDir, const std::shared_ptr request, + const std::string &name) + : name(name), path(workingDir), _baseUrl(baseUrl), request(request){}; + + virtual std::string describe() const = 0; + + // MARK: - properties + const std::string _baseUrl; + std::shared_ptr request; +}; +} // namespace CloudSync diff --git a/lib/include/CloudSync/UsernamePasswordCredentials.hpp b/lib/include/CloudSync/UsernamePasswordCredentials.hpp new file mode 100644 index 0000000..6590330 --- /dev/null +++ b/lib/include/CloudSync/UsernamePasswordCredentials.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "Cloud.hpp" +#include "Credentials.hpp" +#include +#include + +namespace CloudSync { +class UsernamePasswordCredentials : public Credentials { + public: + UsernamePasswordCredentials(const std::string &username, const std::string &password) + : username(std::move(username)), password(std::move(password)){}; + + protected: + void apply(const std::shared_ptr &request) const override; + + private: + std::string username; + std::string password; +}; +} // namespace CloudSync diff --git a/lib/src/Cloud.cpp b/lib/src/Cloud.cpp new file mode 100644 index 0000000..4d999ff --- /dev/null +++ b/lib/src/Cloud.cpp @@ -0,0 +1,17 @@ +#include "CloudSync/Cloud.hpp" +#include "CloudSync/Credentials.hpp" +#include "CloudSync/Exceptions.hpp" +#include "request/Request.hpp" +#include "box/BoxCloud.hpp" +#include + +using namespace CloudSync; + +namespace CloudSync { + +std::ostream &operator<<(std::ostream &output, std::shared_ptr cloud) { + output << "[Cloud url: " << cloud->getBaseUrl() << "]" << std::endl; + return output; +} + +} // namespace CloudSync diff --git a/lib/src/CloudFactory.cpp b/lib/src/CloudFactory.cpp new file mode 100644 index 0000000..d909b9b --- /dev/null +++ b/lib/src/CloudFactory.cpp @@ -0,0 +1,53 @@ +#include "CloudSync/CloudFactory.hpp" +#include "CloudSync/Cloud.hpp" +#include "box/BoxCloud.hpp" +#include "dropbox/DropboxCloud.hpp" +#include "gdrive/GDriveCloud.hpp" +#include "nextcloud/NextcloudCloud.hpp" +#include "onedrive/OneDriveCloud.hpp" +#include "webdav/WebdavCloud.hpp" +#include "request/Request.hpp" +#include "request/curl/CurlRequest.hpp" + +using namespace CloudSync::request::curl; + +using C = CloudSync::request::Request::ConfigurationOption; + +namespace CloudSync { + +CloudFactory::CloudFactory() {} + + +std::shared_ptr CloudFactory::getRequestImplementation() { + if (this->requestImplementation == nullptr) { + this->requestImplementation = std::make_shared(); + } + this->requestImplementation->setOption(C::FOLLOW_REDIRECT, true); + return this->requestImplementation; +} + +std::shared_ptr CloudFactory::webdav(const std::string &url) { + return std::make_shared(url, this->getRequestImplementation()); +} + +std::shared_ptr CloudFactory::nextcloud(const std::string &url) { + return std::make_shared(url, this->getRequestImplementation()); +} + +std::shared_ptr CloudFactory::dropbox() { + return std::make_shared(this->getRequestImplementation()); +} + +std::shared_ptr CloudFactory::box() { + return std::make_shared(this->getRequestImplementation()); +} + +std::shared_ptr CloudFactory::onedrive(const std::string &drive) { + return std::make_shared(drive, this->getRequestImplementation()); +} + +std::shared_ptr CloudFactory::gdrive(const std::string &rootName) { + return std::make_shared(rootName, this->getRequestImplementation()); +} + +} // namespace CloudSync diff --git a/lib/src/CloudImpl.cpp b/lib/src/CloudImpl.cpp new file mode 100644 index 0000000..a64df65 --- /dev/null +++ b/lib/src/CloudImpl.cpp @@ -0,0 +1,40 @@ +#include "CloudImpl.hpp" +#include "CloudSync/Credentials.hpp" +#include "CloudSync/Proxy.hpp" +#include "request/Request.hpp" +#include "request/curl/CurlRequest.hpp" + +using namespace CloudSync::request::curl; + + +using C = CloudSync::request::Request::ConfigurationOption; + +namespace CloudSync { + +CloudImpl::CloudImpl(const std::string &url, const std::shared_ptr &request) + : baseUrl(url), request(request) {} + +std::shared_ptr CloudImpl::login(const Credentials &credentials) { + this->request->setTokenRequestUrl(this->getTokenUrl()); + credentials.apply(this->request); + return this->shared_from_this(); +} + +std::string CloudImpl::getCurrentRefreshToken() const { + return this->request->getCurrentRefreshToken(); +} + +std::shared_ptr CloudImpl::proxy(const Proxy &proxy) { + proxy.apply(this->request); + return this->shared_from_this(); +} + +void CloudImpl::ping() const { + this->root()->ls(); +} + +std::string CloudImpl::getBaseUrl() const { + return this->baseUrl; +} + +} // namespace CloudSync diff --git a/lib/src/CloudImpl.hpp b/lib/src/CloudImpl.hpp new file mode 100644 index 0000000..7c9180e --- /dev/null +++ b/lib/src/CloudImpl.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "CloudSync/Cloud.hpp" + +namespace CloudSync { +class CloudImpl : public Cloud, public std::enable_shared_from_this { + public: + void ping() const override; + std::shared_ptr login(const Credentials &credentials) override; + std::string getCurrentRefreshToken() const override; + std::shared_ptr proxy(const Proxy &proxy) override; + std::string getBaseUrl() const override; + virtual ~CloudImpl() = default; + + protected: + CloudImpl(const std::string &url, const std::shared_ptr &request); + std::shared_ptr request; + std::string baseUrl; +}; +} // namespace CloudSync diff --git a/lib/src/Directory.cpp b/lib/src/Directory.cpp new file mode 100644 index 0000000..ed737dc --- /dev/null +++ b/lib/src/Directory.cpp @@ -0,0 +1,12 @@ +#include "CloudSync/Directory.hpp" + +namespace CloudSync { +Directory::Directory( + const std::string &baseUrl, const std::string &dir, const std::shared_ptr &request, + const std::string &name = "") + : Resource(baseUrl, dir, request, name) {} + +std::string Directory::pwd() const { + return this->path; +} +} // namespace CloudSync diff --git a/lib/src/OAuth2Credentials.cpp b/lib/src/OAuth2Credentials.cpp new file mode 100644 index 0000000..edbcea4 --- /dev/null +++ b/lib/src/OAuth2Credentials.cpp @@ -0,0 +1,17 @@ +#include "CloudSync/OAuth2Credentials.hpp" +#include "CloudSync/Exceptions.hpp" +#include "request/Request.hpp" +#include +#include + +using namespace std::chrono; +using namespace std::literals::chrono_literals; +using json = nlohmann::json; +using namespace CloudSync::request; + +namespace CloudSync { + +void OAuth2Credentials::apply(const std::shared_ptr &request) const { + request->setOAuth2(accessToken, refreshToken, expires); +} +} // namespace CloudSync diff --git a/lib/src/Proxy.cpp b/lib/src/Proxy.cpp new file mode 100644 index 0000000..5300226 --- /dev/null +++ b/lib/src/Proxy.cpp @@ -0,0 +1,12 @@ +#include "CloudSync/Proxy.hpp" +#include "request/Request.hpp" + +namespace CloudSync { + +const Proxy Proxy::NOPROXY = Proxy(""); + +void Proxy::apply(const std::shared_ptr &request) const { + request->setProxy(this->url, this->username, this->password); +} + +} // namespace CloudSync diff --git a/lib/src/ResourceImpl.hpp b/lib/src/ResourceImpl.hpp new file mode 100644 index 0000000..3897e60 --- /dev/null +++ b/lib/src/ResourceImpl.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "CloudSync/Permissions.hpp" +#include "CloudSync/Resource.hpp" +#include +#include + +using namespace std::chrono; + +namespace CloudSync { +class ResourceImpl : public Resource { + public: + ResourceImpl(const std::string &name, system_clock::time_point lastModified, Permissions permissions) + : _name(name), _lastModified(lastModified), _permissions(permissions) {} + std::string name() override { + return _name; + }; + system_clock::time_point lastModified() override { + return _lastModified; + }; + Permissions permissions() override { + return _permissions; + }; + + private: + std::string _name; + system_clock::time_point _lastModified; + Permissions _permissions; +}; +} // namespace CloudSync diff --git a/lib/src/UsernamePasswordCredentials.cpp b/lib/src/UsernamePasswordCredentials.cpp new file mode 100644 index 0000000..1791898 --- /dev/null +++ b/lib/src/UsernamePasswordCredentials.cpp @@ -0,0 +1,10 @@ +#include "CloudSync/UsernamePasswordCredentials.hpp" +#include "request/Request.hpp" + +using namespace CloudSync::request; + +namespace CloudSync { +void UsernamePasswordCredentials::apply(const std::shared_ptr &request) const { + request->setBasicAuth(username, password); +} +} // namespace CloudSync diff --git a/lib/src/box/BoxCloud.hpp b/lib/src/box/BoxCloud.hpp new file mode 100644 index 0000000..dc24a40 --- /dev/null +++ b/lib/src/box/BoxCloud.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "BoxDirectory.hpp" +#include "CloudImpl.hpp" +#include "request/Request.hpp" + +namespace CloudSync::box { +class BoxCloud : public CloudImpl { + public: + BoxCloud(const std::shared_ptr &request) : CloudImpl("https://api.box.com", request) {} + + std::string getAuthorizeUrl() const override { + return "https://account.box.com/api/oauth2/authorize"; + } + std::string getTokenUrl() const override { + return "https://api.box.com/oauth2/token"; + } + + std::shared_ptr root() const override { + return std::make_shared("0", "0", "/", this->request, ""); + } + + static void handleExceptions(const std::exception_ptr &e, const std::string &resourcePath) { + try { + std::rethrow_exception(e); + } catch (request::Response::NotFound &e) { + throw Resource::NoSuchFileOrDirectory(resourcePath); + } catch (request::Response::Forbidden &e) { + throw Resource::PermissionDenied(resourcePath); + } catch (request::Response::Unauthorized &e) { + throw Cloud::AuthorizationFailed(); + } catch (request::Response::ResponseException &e) { + throw Cloud::CommunicationError(e.what()); + } catch (request::Request::RequestException &e) { + throw Cloud::CommunicationError(e.what()); + } catch (nlohmann::json::exception &e) { + throw Cloud::InvalidResponse(e.what()); + } + } + + std::string getUserDisplayName() const override { + std::string userDisplayName; + try { + const auto getResponse = this->request->GET("https://api.box.com/2.0/users/me").json(); + userDisplayName = getResponse.at("name"); + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), ""); + } + return userDisplayName; + }; +}; +} // namespace CloudSync::box diff --git a/lib/src/box/BoxDirectory.cpp b/lib/src/box/BoxDirectory.cpp new file mode 100644 index 0000000..a8b930e --- /dev/null +++ b/lib/src/box/BoxDirectory.cpp @@ -0,0 +1,217 @@ +#include "BoxDirectory.hpp" +#include "BoxCloud.hpp" +#include "BoxFile.hpp" +#include "request/Request.hpp" +#include +#include + +using namespace CloudSync::request; +using P = CloudSync::request::Request::ParameterType; +using json = nlohmann::json; + +namespace CloudSync::box { +std::vector> BoxDirectory::ls() const { + std::vector> resources; + try { + json responseJson = this->request->GET("https://api.box.com/2.0/folders/" + this->resourceId + "/items").json(); + for (const auto &entry : responseJson.at("entries")) { + resources.push_back(this->parseEntry(entry)); + } + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), path); + } + return resources; +} + +std::shared_ptr BoxDirectory::cd(const std::string &path) const { + std::shared_ptr newDir; + // calculate "diff" between current position & wanted path. What do we need + // to do to get there? + const auto relativePath = + std::filesystem::path(this->path + "/" + path).lexically_normal().lexically_relative(this->path); + try { + if (relativePath == ".") { + // no path change required, return current instance + newDir = std::make_shared( + this->resourceId, + this->parentResourceId, + this->path, + this->request, + this->name); + } else if (relativePath.begin() == --relativePath.end() && *relativePath.begin() != "..") { + // depth of navigation = 1, get a list of all folders in folder and + // pick the desired one. + newDir = this->child(relativePath.string()); + } else { + // depth of navigation > 1, slowly navigate from folder to folder + // one by one. + std::shared_ptr currentDir = std::make_shared( + this->resourceId, + this->parentResourceId, + this->path, + this->request, + this->name); + for (const auto &pathComponent : relativePath) { + if (pathComponent == "..") { + currentDir = currentDir->parent(); + } else { + currentDir = std::static_pointer_cast(currentDir->cd(pathComponent.string())); + } + } + newDir = currentDir; + } + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), path); + } + return newDir; +} + +void BoxDirectory::rmdir() const { + try { + if (this->path != "/") { + this->request->DELETE("https://api.box.com/2.0/folders/" + this->resourceId); + } else { + throw PermissionDenied("deleting the root folder is not allowed"); + } + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), this->path); + } +} + +std::shared_ptr BoxDirectory::mkdir(const std::string &path) const { + std::shared_ptr newDir; + std::string folderName; + try { + const auto baseDir = this->parent(path, folderName); + json responseJson = this->request + ->POST( + "https://api.box.com/2.0/folders", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"name", folderName}, {"parent", {{"id", baseDir->resourceId}}}}.dump()) + .json(); + newDir = std::dynamic_pointer_cast(baseDir->parseEntry(responseJson, "folder")); + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), path); + } + return newDir; +} + +std::shared_ptr BoxDirectory::touch(const std::string &path) const { + std::shared_ptr newFile; + std::string fileName; + try { + const auto baseDir = this->parent(path, fileName); + json attributesJson = {{"name", fileName}, {"parent", {{"id", baseDir->resourceId}}}}; + const auto responseJson = + this->request + ->POST( + "https://upload.box.com/api/2.0/files/content", + {{P::MIME_POSTFIELDS, + {{"attributes", json{{"name", fileName}, {"parent", {{"id", baseDir->resourceId}}}}.dump()}}}, + {P::MIME_POSTFILES, {{"file", ""}}}}) + .json(); + newFile = std::dynamic_pointer_cast(baseDir->parseEntry(responseJson.at("entries").at(0), "file")); + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), path); + } + return newFile; +} + +std::shared_ptr BoxDirectory::file(const std::string &path) const { + std::shared_ptr file; + std::string fileName; + try { + const auto baseDir = this->parent(path, fileName); + json responseJson = + this->request->GET("https://api.box.com/2.0/folders/" + baseDir->resourceId + "/items").json(); + for (const auto &entryJson : responseJson.at("entries")) { + const auto entry = baseDir->parseEntry(entryJson); + if (entry->name == fileName) { + file = std::dynamic_pointer_cast(baseDir->parseEntry(entryJson, "file")); + break; + } + } + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), path); + } + return file; +} + +std::shared_ptr BoxDirectory::parent() const { + std::shared_ptr parentDir; + json responseJson = this->request->GET("https://api.box.com/2.0/folders/" + this->parentResourceId).json(); + return std::dynamic_pointer_cast( + this->parseEntry(responseJson, "folder", std::filesystem::path(this->path).parent_path().generic_string())); +} + +std::shared_ptr BoxDirectory::parent(const std::string &path, std::string &folderName) const { + const auto relativePath = + std::filesystem::path(this->path + "/" + path).lexically_normal().lexically_relative(this->path); + const auto relativeParentPath = relativePath.parent_path(); + folderName = relativePath.lexically_relative(relativeParentPath).string(); + return std::static_pointer_cast(this->cd(relativeParentPath.string())); +} + +std::shared_ptr BoxDirectory::child(const std::string &name) const { + std::shared_ptr childDir; + json responseJson = this->request->GET("https://api.box.com/2.0/folders/" + this->resourceId + "/items").json(); + for (const auto &entryJson : responseJson.at("entries")) { + const auto entry = this->parseEntry(entryJson); + if (entry->name == name) { + childDir = std::dynamic_pointer_cast(this->parseEntry(entryJson, "folder")); + break; + } + } + if (childDir == nullptr) { + throw NoSuchFileOrDirectory(std::filesystem::path(this->path + "/" + name).lexically_normal().generic_string()); + } + return childDir; +} + +std::shared_ptr +BoxDirectory::parseEntry(const json &entry, const std::string &expectedType, const std::string &customPath) const { + std::shared_ptr resource; + const std::string resourceId = entry.at("id"); + + if (resourceId != "0") { + const std::string etag = entry.at("etag"); + const std::string resourceType = entry.at("type"); + const std::string name = entry.at("name"); + + if (expectedType != "" && expectedType != resourceType) { + throw NoSuchFileOrDirectory("expected resource type does not match real resource type"); + } + const auto newResourcePath = std::filesystem::path(this->path + "/" + name).lexically_normal().generic_string(); + if (resourceType == "file") { + resource = std::make_shared( + resourceId, + customPath != "" ? customPath : newResourcePath, + this->request, + name, + etag); + } else if (resourceType == "folder") { + + // extract parent resourceId from json payload if possible. + // Otherwise fallback to pointing to current dir. + std::string parentResourceId; + if (entry.find("path_collection") != entry.end() && entry["path_collection"]["total_count"] > 0) { + parentResourceId = entry["path_collection"]["entries"][0]["id"]; + } else { + parentResourceId = this->resourceId; + } + resource = std::make_shared( + resourceId, + parentResourceId, + customPath != "" ? customPath : newResourcePath, + this->request, + name); + } else { + throw Cloud::CommunicationError("unknown resource type"); + } + } else { + resource = std::make_shared("0", "0", "/", this->request, ""); + } + + return resource; +} +} // namespace CloudSync::box diff --git a/lib/src/box/BoxDirectory.hpp b/lib/src/box/BoxDirectory.hpp new file mode 100644 index 0000000..1de7973 --- /dev/null +++ b/lib/src/box/BoxDirectory.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "CloudSync/Directory.hpp" +#include "request/Response.hpp" +#include + +using json = nlohmann::json; + +namespace CloudSync::box { +class BoxDirectory : public Directory { + public: + BoxDirectory( + const std::string &resourceId, const std::string &parentResourceId, const std::string &dir, + const std::shared_ptr &request, const std::string &name) + : Directory("", dir, request, name), resourceId(resourceId), parentResourceId(parentResourceId){}; + std::vector> ls() const override; + std::shared_ptr cd(const std::string &path) const override; + void rmdir() const override; + std::shared_ptr mkdir(const std::string &path) const override; + std::shared_ptr touch(const std::string &path) const override; + std::shared_ptr file(const std::string &path) const override; + + private: + const std::string resourceId; + const std::string parentResourceId; + + std::shared_ptr + parseEntry(const json &entry, const std::string &expectedType = "", const std::string &customPath = "") const; + /// @return parent of the current directory + std::shared_ptr parent() const; + /// @return parent of the given path + std::shared_ptr parent(const std::string &path, std::string &folderName) const; + std::shared_ptr child(const std::string &name) const; +}; +} // namespace CloudSync::box diff --git a/lib/src/box/BoxFile.cpp b/lib/src/box/BoxFile.cpp new file mode 100644 index 0000000..5d45bbd --- /dev/null +++ b/lib/src/box/BoxFile.cpp @@ -0,0 +1,62 @@ +#include "BoxFile.hpp" +#include "BoxCloud.hpp" +#include "request/Request.hpp" + +using namespace CloudSync::request; +using P = Request::ParameterType; + +namespace CloudSync::box { +void BoxFile::rm() { + try { + this->request->DELETE("https://api.box.com/2.0/files/" + this->resourceId); + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), this->path); + } +} + +bool BoxFile::pollChange(bool longPoll) { + bool hasChanged = false; + try { + if (longPoll) { + // TODO implement box change longpoll + throw std::logic_error("not yet implemented"); + } else { + const auto responseJson = this->request->GET("https://api.box.com/2.0/files/" + this->resourceId).json(); + const std::string newRevision = responseJson.at("etag"); + if (this->revision() != newRevision) { + this->_revision = newRevision; + hasChanged = true; + } + } + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), this->path); + } + return hasChanged; +} + +std::string BoxFile::read() const { + std::string fileContent; + try { + fileContent = this->request->GET("https://api.box.com/2.0/files/" + this->resourceId + "/content").data; + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), this->path); + } + return fileContent; +} +void BoxFile::write(const std::string &content) { + try { + const auto responseJson = this->request + ->POST( + "https://upload.box.com/api/2.0/files/" + this->resourceId + "/content", + {{P::MIME_POSTFIELDS, + { + {"attributes", "{}"}, + }}, + {P::MIME_POSTFILES, {{"file", content}}}}) + .json(); + this->_revision = responseJson.at("entries").at(0).at("etag"); + } catch (...) { + BoxCloud::handleExceptions(std::current_exception(), this->path); + } +} +} // namespace CloudSync::box diff --git a/lib/src/box/BoxFile.hpp b/lib/src/box/BoxFile.hpp new file mode 100644 index 0000000..df3d61d --- /dev/null +++ b/lib/src/box/BoxFile.hpp @@ -0,0 +1,22 @@ +#include "CloudSync/File.hpp" +#include "request/Request.hpp" + +namespace CloudSync::box { +class BoxFile : public File { + public: + BoxFile( + const std::string &resourceId, const std::string &dir, const std::shared_ptr &request, + const std::string &name, const std::string &revision) + : File("", dir, request, name, revision), resourceId(resourceId){}; + void rm() override; + bool pollChange(bool longPoll = false) override; + bool supportsLongPoll() const override { + return true; + } + std::string read() const override; + void write(const std::string &content) override; + + private: + const std::string resourceId; +}; +} // namespace CloudSync::box diff --git a/lib/src/dropbox/DropboxCloud.hpp b/lib/src/dropbox/DropboxCloud.hpp new file mode 100644 index 0000000..6e2ae1d --- /dev/null +++ b/lib/src/dropbox/DropboxCloud.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "CloudImpl.hpp" +#include "request/Request.hpp" +#include "DropboxDirectory.hpp" + +using namespace CloudSync::request; +using P = Request::ParameterType; + +namespace CloudSync::dropbox { +class DropboxCloud : public CloudImpl { + public: + DropboxCloud(const std::shared_ptr &request) : CloudImpl("https://www.dropbox.com", request) {} + + std::string getAuthorizeUrl() const override { + return "https://www.dropbox.com/oauth2/authorize"; + } + std::string getTokenUrl() const override { + return "https://api.dropboxapi.com/oauth2/token"; + } + std::shared_ptr root() const override { + return std::make_shared("/", this->request, ""); + } + + static void handleExceptions(const std::exception_ptr &e, const std::string &resourcePath) { + try { + std::rethrow_exception(e); + } catch (request::Response::Conflict &e) { + try { + json errorJson = json::parse(e.data()); + if ((errorJson["error"][".tag"] == "path" && errorJson["error"]["path"][".tag"] == "not_found") || + (errorJson["error"][".tag"] == "path_lookup" && + errorJson["error"]["path_lookup"][".tag"] == "not_found")) { + throw Resource::NoSuchFileOrDirectory(resourcePath); + } else { + throw Cloud::CommunicationError(e.what()); + } + } catch (json::exception &e) { + throw Cloud::InvalidResponse(e.what()); + } + } catch (request::Response::Unauthorized &e) { + throw Cloud::AuthorizationFailed(); + } catch (request::Response::ResponseException &e) { + throw Cloud::CommunicationError(e.what()); + } catch (Response::ParseError &e) { + throw Cloud::InvalidResponse(e.what()); + } catch (request::Request::RequestException &e) { + throw Cloud::CommunicationError(e.what()); + } catch (json::exception &e) { + throw Cloud::InvalidResponse(e.what()); + } + } + + std::string getUserDisplayName() const override { + std::string userDisplayName; + try { + const auto getResponse = + this->request->POST("https://api.dropboxapi.com/2/users/get_current_account").json(); + userDisplayName = getResponse.at("name").at("display_name"); + + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), ""); + } + return userDisplayName; + }; +}; +} // namespace CloudSync::dropbox diff --git a/lib/src/dropbox/DropboxDirectory.cpp b/lib/src/dropbox/DropboxDirectory.cpp new file mode 100644 index 0000000..4738191 --- /dev/null +++ b/lib/src/dropbox/DropboxDirectory.cpp @@ -0,0 +1,157 @@ +#include "DropboxDirectory.hpp" +#include "request/Request.hpp" +#include "request/Response.hpp" +#include "DropboxCloud.hpp" +#include "DropboxFile.hpp" +#include +#include + +using json = nlohmann::json; +using namespace CloudSync::request; +using P = Request::ParameterType; + +namespace CloudSync::dropbox { +std::vector> DropboxDirectory::ls() const { + // TODO implement paging logic when list is split in multiple responses + const auto resourcePath = DropboxDirectory::parsePath(this->path); + std::vector> resources; + try { + json responseJson = this->request + ->POST( + "https://api.dropboxapi.com/2/files/list_folder", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"path", resourcePath}, {"recursive", false}}.dump()) + .json(); + for (const auto &entry : responseJson.at("entries")) { + resources.push_back(this->parseEntry(entry)); + } + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), resourcePath); + } + return resources; +} +std::shared_ptr DropboxDirectory::cd(const std::string &path) const { + const auto resourcePath = DropboxDirectory::parsePath(this->path + "/" + path); + std::shared_ptr directory; + // get_metadata is not supported for the root folder + if (resourcePath != "") { + try { + json responseJson = this->request + ->POST( + "https://api.dropboxapi.com/2/files/get_metadata", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"path", resourcePath}}.dump()) + .json(); + directory = std::dynamic_pointer_cast(this->parseEntry(responseJson)); + + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), resourcePath + "/"); + } + } else { + directory = std::make_shared("/", this->request, ""); + } + return directory; +} + +void DropboxDirectory::rmdir() const { + const auto resourcePath = DropboxDirectory::parsePath(this->path); + if (resourcePath == "") { + throw PermissionDenied("deleting the root folder is not allowed"); + } + try { + this->request->POST( + "https://api.dropboxapi.com/2/files/delete_v2", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"path", resourcePath}}.dump()); + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), resourcePath); + } +} + +std::shared_ptr DropboxDirectory::mkdir(const std::string &path) const { + const auto resourcePath = DropboxDirectory::parsePath(this->path + "/" + path); + std::shared_ptr directory; + try { + json responseJson = this->request + ->POST( + "https://api.dropboxapi.com/2/files/create_folder_v2", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"path", resourcePath}}.dump()) + .json(); + directory = + std::dynamic_pointer_cast(this->parseEntry(responseJson.at("metadata"), "folder")); + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), resourcePath); + } + return directory; +} +std::shared_ptr DropboxDirectory::touch(const std::string &path) const { + const auto resourcePath = DropboxDirectory::parsePath(this->path + "/" + path); + std::shared_ptr file; + try { + const auto responseJson = this->request + ->POST( + "https://content.dropboxapi.com/2/files/upload", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_BINARY}}}, + {P::QUERY_PARAMS, {{"arg", json{{"path", resourcePath}}.dump()}}}}, + "") + .json(); + file = std::dynamic_pointer_cast(this->parseEntry(responseJson, "file")); + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), resourcePath); + } + return file; +} +std::shared_ptr DropboxDirectory::file(const std::string &path) const { + const auto resourcePath = DropboxDirectory::parsePath(this->path + "/" + path); + std::shared_ptr file; + try { + json responseJson = this->request + ->POST( + "https://api.dropboxapi.com/2/files/get_metadata", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"path", resourcePath}}.dump()) + .json(); + file = std::dynamic_pointer_cast(this->parseEntry(responseJson, "file")); + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), resourcePath); + } + return file; +} + +std::shared_ptr +DropboxDirectory::parseEntry(const json &entry, const std::string &resourceTypeFallback) const { + std::shared_ptr resource; + std::string resourceType; + const std::string name = entry.at("name"); + std::string path = entry.at("path_display"); + if (entry.find(".tag") != entry.end()) { + resourceType = entry[".tag"]; + } else { + if (resourceTypeFallback != "") { + resourceType = resourceTypeFallback; + } else { + throw Cloud::CommunicationError("unknown resource type"); + } + } + if (resourceTypeFallback != "" && resourceType != resourceTypeFallback) { + throw NoSuchFileOrDirectory(path); + } + if (resourceType == "folder") { + resource = std::make_shared(path, this->request, name); + } else if (resourceType == "file") { + resource = std::make_shared(path, this->request, name, entry.at("rev")); + } + return resource; +} + +std::string DropboxDirectory::parsePath(const std::string &path) { + std::string parsedPath = std::filesystem::path(path).lexically_normal().generic_string(); + // remove trailing slashes because dropbox won't accept them + while (!parsedPath.empty() && parsedPath.back() == '/') { + parsedPath = parsedPath.erase(parsedPath.size() - 1); + } + return parsedPath; +} + +} // namespace CloudSync::dropbox diff --git a/lib/src/dropbox/DropboxDirectory.hpp b/lib/src/dropbox/DropboxDirectory.hpp new file mode 100644 index 0000000..f8b40bc --- /dev/null +++ b/lib/src/dropbox/DropboxDirectory.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "CloudSync/Directory.hpp" +#include "request/Response.hpp" +#include + +using json = nlohmann::json; + +namespace CloudSync::dropbox { +class DropboxDirectory : public Directory { + public: + DropboxDirectory(const std::string &dir, const std::shared_ptr &request, const std::string &name) + : Directory("", dir, request, name){}; + std::vector> ls() const override; + std::shared_ptr cd(const std::string &path) const override; + void rmdir() const override; + std::shared_ptr mkdir(const std::string &path) const override; + std::shared_ptr touch(const std::string &path) const override; + std::shared_ptr file(const std::string &path) const override; + + private: + /** + * Takes a json object describing a dropbox resource and converts it into a + * Resource object. + * + * @param resourceTypeFallback [""|"folder"|"file"] If the type fallback is + * provided and the json object **does not** contain any type information, + * the fallback type will decide on the resulting Resource type. If the json + * object **does** contains type information, it will be validated against + * the fallback type. The parsing will fail if they don't match up. + */ + std::shared_ptr parseEntry(const json &entry, const std::string &resourceTypeFallback = "") const; + + /** + * Parses a new path-string to make it a valid path that can be passed to + * dropbox. + * + * Normalizes the path (removes all clutter like `//`, `/..`) and removes + * any trailing `/` if present, because dropbox does not accept paths with + * trailing `/` + * @param path a path representation + * @return a normalized path + */ + static std::string parsePath(const std::string &path); +}; +} // namespace CloudSync::dropbox diff --git a/lib/src/dropbox/DropboxFile.cpp b/lib/src/dropbox/DropboxFile.cpp new file mode 100644 index 0000000..e8a4555 --- /dev/null +++ b/lib/src/dropbox/DropboxFile.cpp @@ -0,0 +1,81 @@ +#include "DropboxFile.hpp" +#include "request/Request.hpp" +#include "DropboxCloud.hpp" +#include + +using namespace CloudSync::request; + +using json = nlohmann::json; +using P = Request::ParameterType; + +namespace CloudSync::dropbox { +void DropboxFile::rm() { + try { + this->request->POST( + "https://api.dropboxapi.com/2/files/delete_v2", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"path", this->path}}.dump()); + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), this->path); + } +} + +bool DropboxFile::pollChange(bool longPoll) { + bool hasChanged = false; + try { + if (longPoll) { + // TODO implement dropbox change longpoll + throw std::logic_error("not yet implemented"); + } else { + const auto responseJson = this->request + ->POST( + "https://api.dropboxapi.com/2/files/get_metadata", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"path", this->path}}.dump()) + .json(); + const std::string newRevision = responseJson.at("rev"); + if (this->revision() != newRevision) { + this->_revision = newRevision; + hasChanged = true; + } + } + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), this->path); + } + return hasChanged; +} + +std::string DropboxFile::read() const { + std::string data; + try { + data = this->request + ->POST( + "https://content.dropboxapi.com/2/files/download", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_TEXT}}}, + {P::QUERY_PARAMS, {{"arg", json{{"path", this->path}}.dump()}}}}, + "") + .data; + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), this->path); + } + return data; +} +void DropboxFile::write(const std::string &content) { + try { + const auto responseJson = + this->request + ->POST( + "https://content.dropboxapi.com/2/files/upload", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_BINARY}}}, + {P::QUERY_PARAMS, + {{"arg", + json{{"path", this->path}, {"mode", {{".tag", "update"}, {"update", this->revision()}}}} + .dump()}}}}, + content) + .json(); + this->_revision = responseJson.at("rev"); + } catch (...) { + DropboxCloud::handleExceptions(std::current_exception(), this->path); + } +} +} // namespace CloudSync::dropbox diff --git a/lib/src/dropbox/DropboxFile.hpp b/lib/src/dropbox/DropboxFile.hpp new file mode 100644 index 0000000..4dcf137 --- /dev/null +++ b/lib/src/dropbox/DropboxFile.hpp @@ -0,0 +1,19 @@ +#include "CloudSync/File.hpp" +#include "request/Request.hpp" + +namespace CloudSync::dropbox { +class DropboxFile : public File { + public: + DropboxFile( + const std::string &dir, const std::shared_ptr &request, const std::string &name, + const std::string &revision) + : File("", dir, request, name, revision){}; + void rm() override; + bool pollChange(bool longPoll = false) override; + bool supportsLongPoll() const override { + return true; + } + std::string read() const override; + void write(const std::string &content) override; +}; +} // namespace CloudSync::dropbox diff --git a/lib/src/gdrive/GDriveCloud.hpp b/lib/src/gdrive/GDriveCloud.hpp new file mode 100644 index 0000000..28d50e0 --- /dev/null +++ b/lib/src/gdrive/GDriveCloud.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "CloudImpl.hpp" +#include "request/Request.hpp" +#include "request/Response.hpp" +#include "GDriveDirectory.hpp" + +namespace CloudSync::gdrive { +class GDriveCloud : public CloudImpl { + public: + GDriveCloud(const std::string &rootName, const std::shared_ptr &request) + : CloudImpl("https://www.googleapis.com/drive/v2", request), rootName(rootName) {} + std::string getAuthorizeUrl() const override { + return "https://accounts.google.com/o/oauth2/v2/auth"; + } + std::string getTokenUrl() const override { + return "https://oauth2.googleapis.com/token"; + } + std::shared_ptr root() const override { + return std::make_shared( + this->baseUrl, + this->rootName, + this->rootName, + this->rootName, + "/", + this->request, + ""); + } + static void handleExceptions(const std::exception_ptr &e, const std::string &resourcePath) { + try { + std::rethrow_exception(e); + } catch (request::Response::NotFound &e) { + try { + const auto errorMessage = json::parse(e.data()); + if (errorMessage["error"]["errors"][0]["reason"] == "notFound") { + throw Resource::NoSuchFileOrDirectory(resourcePath); + } else { + throw Cloud::CommunicationError("unknown error response: " + e.data()); + } + } catch (json::exception &e) { + throw Cloud::InvalidResponse(e.what()); + } + } catch (request::Response::Unauthorized &e) { + throw Cloud::AuthorizationFailed(); + } catch (request::Response::PreconditionFailed &e) { + throw Resource::ResourceHasChanged(resourcePath); + } catch (request::Response::Forbidden &) { + throw Resource::PermissionDenied(resourcePath); + } catch (json::exception &e) { + throw Cloud::InvalidResponse(e.what()); + } catch (request::Request::RequestException &e) { + throw Cloud::CommunicationError(e.what()); + } + }; + + std::string getUserDisplayName() const override { + std::string userDisplayName; + try { + const auto getResponse = this->request->GET("https://www.googleapis.com/userinfo/v2/me").json(); + userDisplayName = getResponse.at("name"); + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), ""); + } + return userDisplayName; + }; + + private: + std::string rootName; +}; +} // namespace CloudSync::gdrive diff --git a/lib/src/gdrive/GDriveDirectory.cpp b/lib/src/gdrive/GDriveDirectory.cpp new file mode 100644 index 0000000..2b0ecb1 --- /dev/null +++ b/lib/src/gdrive/GDriveDirectory.cpp @@ -0,0 +1,254 @@ +#include "GDriveDirectory.hpp" +#include "request/Request.hpp" +#include "GDriveCloud.hpp" +#include "GDriveFile.hpp" +#include + +using json = nlohmann::json; +using namespace CloudSync::request; +using P = Request::ParameterType; + +namespace CloudSync::gdrive { +std::vector> GDriveDirectory::ls() const { + std::vector> resourceList; + try { + const auto responseJson = this->request + ->GET( + this->_baseUrl + "/files", + {{P::QUERY_PARAMS, + {{"q", "'" + this->resourceId + "' in parents and trashed = false"}, + {"fields", "items(kind,id,title,mimeType,etag,parents(id,isRoot))"}}}}) + .json(); + for (const auto &file : responseJson.at("items")) { + resourceList.push_back(this->parseFile(file)); + } + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), this->path); + } + return resourceList; +} +std::shared_ptr GDriveDirectory::cd(const std::string &path) const { + std::shared_ptr newDir; + // calculate "diff" between current position & wanted path. What do we need + // to do to get there? + const auto relativePath = + std::filesystem::path(this->path + "/" + path).lexically_normal().lexically_relative(this->path); + try { + if (relativePath == ".") { + // no path change required, return current dir + newDir = std::make_shared( + this->_baseUrl, + this->rootName, + this->resourceId, + this->parentResourceId, + this->path, + this->request, + this->name); + } else if (relativePath.begin() == --relativePath.end() && *relativePath.begin() != "..") { + // depth of navigation = 1, get a list of all folders in folder and + // pick the desired one. + newDir = this->child(relativePath.string()); + } else { + // depth of navigation > 1, slowly navigate from folder to folder + // one by one. + std::shared_ptr currentDir = std::make_shared( + this->_baseUrl, + this->rootName, + this->resourceId, + this->parentResourceId, + this->path, + this->request, + this->name); + for (const auto &pathComponent : relativePath) { + if (pathComponent == "..") { + currentDir = currentDir->parent(); + } else { + currentDir = std::static_pointer_cast(currentDir->cd(pathComponent.string())); + } + } + newDir = currentDir; + } + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), this->path); + } + + return newDir; +} +void GDriveDirectory::rmdir() const { + try { + if (this->path != "/") { + this->request->DELETE(this->_baseUrl + "/files/" + this->resourceId); + } else { + throw PermissionDenied("deleting the root folder is not allowed"); + } + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), this->path); + } +} +std::shared_ptr GDriveDirectory::mkdir(const std::string &path) const { + std::shared_ptr newDir; + std::string folderName; + try { + const auto baseDir = this->parent(path, folderName); + const auto responseJson = + this->request + ->POST( + this->_baseUrl + "/files", + {{P::QUERY_PARAMS, {{"fields", "kind,id,title,mimeType,etag,parents(id,isRoot)"}}}, + {P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{ + {"mimeType", "application/vnd.google-apps.folder"}, + {"title", folderName}, + {"parents", {{{"id", baseDir->resourceId}}}}} + .dump()) + .json(); + newDir = std::dynamic_pointer_cast(baseDir->parseFile(responseJson, ResourceType::FOLDER)); + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), path); + } + return newDir; +} +std::shared_ptr GDriveDirectory::touch(const std::string &path) const { + std::shared_ptr newFile; + std::string fileName; + try { + const auto baseDir = this->parent(path, fileName); + const auto responseJson = + this->request + ->POST( + this->_baseUrl + "/files", + {{P::QUERY_PARAMS, {{"fields", "kind,id,title,mimeType,etag,parents(id,isRoot)"}}}, + {P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"mimeType", "text/plain"}, {"title", fileName}, {"parents", {{{"id", baseDir->resourceId}}}}} + .dump()) + .json(); + newFile = std::dynamic_pointer_cast(baseDir->parseFile(responseJson, ResourceType::FILE)); + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), path); + } + return newFile; +} +std::shared_ptr GDriveDirectory::file(const std::string &path) const { + std::shared_ptr file; + std::string fileName; + try { + const auto baseDir = this->parent(path, fileName); + const auto responseJson = + this->request + ->GET( + this->_baseUrl + "/files", + {{P::QUERY_PARAMS, + {{"q", + "'" + baseDir->resourceId + "' in parents and title = '" + fileName + "' and trashed = false"}, + {"fields", "items(kind,id,title,mimeType,etag,parents(id,isRoot))"}}}}) + .json(); + if (responseJson.at("items").size() >= 1) { + file = std::dynamic_pointer_cast( + baseDir->parseFile(responseJson.at("items").at(0), ResourceType::FILE)); + } else { + throw NoSuchFileOrDirectory(fileName); + } + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), path); + } + return file; +} + +std::shared_ptr +GDriveDirectory::parseFile(const json &file, ResourceType expectedType, const std::string &customPath) const { + std::shared_ptr resource; + const std::string name = file.at("title"); + const std::string id = file.at("id"); + const std::string mimeType = file.at("mimeType"); + const std::string resourcePath = std::filesystem::path(this->path + "/" + name).lexically_normal().generic_string(); + std::string parentId; + if (file.at("parents").at(0).at("isRoot") == true) { + parentId = "root"; + } else { + parentId = file.at("parents").at(0).at("id"); + } + if (file.at("kind") != "drive#file") { + throw Cloud::CommunicationError("unknown file kind"); + } + if (mimeType == "application/vnd.google-apps.folder") { + if (expectedType != ResourceType::ANY && expectedType != ResourceType::FOLDER) { + throw NoSuchFileOrDirectory(resourcePath); + } + resource = std::make_shared( + this->_baseUrl, + this->rootName, + id, + parentId, + customPath != "" ? customPath : resourcePath, + this->request, + name); + } else { + if (expectedType != ResourceType::ANY && expectedType != ResourceType::FILE) { + throw NoSuchFileOrDirectory(name); + } + const std::string etag = file.at("etag"); + resource = std::make_shared( + this->_baseUrl, + id, + customPath != "" ? customPath : resourcePath, + this->request, + name, + etag); + } + + return resource; +} + +/// @return parent of the current directory +std::shared_ptr GDriveDirectory::parent() const { + const auto parentPath = std::filesystem::path(this->path).parent_path(); + std::shared_ptr parentDirectory; + if (parentPath == "/") { + // query for root is not possible. + parentDirectory = std::make_shared( + this->_baseUrl, + this->rootName, + this->rootName, + this->rootName, + "/", + this->request, + ""); + } else { + const auto responseJson = + this->request + ->GET( + this->_baseUrl + "/files/" + this->parentResourceId, + {{P::QUERY_PARAMS, {{"fields", "kind,id,title,mimeType,etag,parents(id,isRoot)"}}}}) + .json(); + parentDirectory = + std::dynamic_pointer_cast(this->parseFile(responseJson, ResourceType::FOLDER, parentPath.string())); + } + return parentDirectory; +} +/// @return parent of the given path +std::shared_ptr GDriveDirectory::parent(const std::string &path, std::string &folderName) const { + const auto relativePath = + std::filesystem::path(this->path + "/" + path).lexically_normal().lexically_relative(this->path); + const auto relativeParentPath = relativePath.parent_path(); + folderName = relativePath.lexically_relative(relativeParentPath).string(); + return std::static_pointer_cast(this->cd(relativeParentPath.string())); +} +std::shared_ptr GDriveDirectory::child(const std::string &name) const { + std::shared_ptr childDir; + const auto responseJson = + this->request + ->GET( + this->_baseUrl + "/files", + {{P::QUERY_PARAMS, + {{"q", "'" + this->resourceId + "' in parents and title = '" + name + "' and trashed = false"}, + {"fields", "items(kind,id,title,mimeType,etag,parents(id,isRoot))"}}}}) + .json(); + if (responseJson.at("items").size() >= 1) { + childDir = std::dynamic_pointer_cast( + this->parseFile(responseJson.at("items").at(0), ResourceType::FOLDER)); + } else { + throw NoSuchFileOrDirectory(std::filesystem::path(this->path + "/" + name).lexically_normal().generic_string()); + } + return childDir; +} +} // namespace CloudSync::gdrive diff --git a/lib/src/gdrive/GDriveDirectory.hpp b/lib/src/gdrive/GDriveDirectory.hpp new file mode 100644 index 0000000..a7cc51a --- /dev/null +++ b/lib/src/gdrive/GDriveDirectory.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "CloudSync/Directory.hpp" +#include + +using json = nlohmann::json; + +namespace CloudSync::gdrive { +class GDriveDirectory : public Directory { + public: + GDriveDirectory( + const std::string &baseUrl, const std::string &rootName, const std::string &resourceId, + const std::string &parentResourceId, const std::string &dir, const std::shared_ptr &request, + const std::string &name) + : Directory(baseUrl, dir, request, name), resourceId(resourceId), parentResourceId(parentResourceId), + rootName(rootName){}; + std::vector> ls() const override; + std::shared_ptr cd(const std::string &path) const override; + void rmdir() const override; + std::shared_ptr mkdir(const std::string &path) const override; + std::shared_ptr touch(const std::string &path) const override; + std::shared_ptr file(const std::string &path) const override; + + private: + enum ResourceType { ANY, FILE, FOLDER }; + const std::string resourceId; + const std::string parentResourceId; + const std::string rootName; + std::shared_ptr parseFile( + const json &file, ResourceType expectedType = ResourceType::ANY, const std::string &customPath = "") const; + + /// @return parent of the current directory + std::shared_ptr parent() const; + /// @return parent of the given path + std::shared_ptr parent(const std::string &path, std::string &folderName) const; + std::shared_ptr child(const std::string &name) const; +}; +} // namespace CloudSync::gdrive diff --git a/lib/src/gdrive/GDriveFile.cpp b/lib/src/gdrive/GDriveFile.cpp new file mode 100644 index 0000000..1a26135 --- /dev/null +++ b/lib/src/gdrive/GDriveFile.cpp @@ -0,0 +1,61 @@ +#include "GDriveFile.hpp" +#include "request/Request.hpp" +#include "GDriveCloud.hpp" + +using namespace CloudSync::request; +using P = Request::ParameterType; + +namespace CloudSync::gdrive { +void GDriveFile::rm() { + try { + this->request->DELETE(this->_baseUrl + "/files/" + this->resourceId); + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), this->path); + } +} +bool GDriveFile::pollChange(bool longPoll) { + bool hasChanged = false; + try { + const auto responseJson = + this->request->GET(this->_baseUrl + "/files/" + this->resourceId, {{P::QUERY_PARAMS, {{"fields", "etag"}}}}) + .json(); + const std::string newRevision = responseJson.at("etag"); + if (this->revision() != newRevision) { + hasChanged = true; + this->_revision = newRevision; + } + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), this->path); + } + return hasChanged; +} +std::string GDriveFile::read() const { + std::string content; + try { + const auto responseJson = + this->request + ->GET(this->_baseUrl + "/files/" + this->resourceId, {{P::QUERY_PARAMS, {{"fields", "downloadUrl"}}}}) + .json(); + const std::string webContentLink = responseJson.at("downloadUrl"); + content = this->request->GET(webContentLink).data; + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), this->path); + } + return content; +} +void GDriveFile::write(const std::string &content) { + try { + const auto res = + this->request + ->PUT( + "https://www.googleapis.com/upload/drive/v2/files/" + this->resourceId, + {{P::QUERY_PARAMS, {{"uploadType", "media"}, {"fields", "etag"}}}, + {P::HEADERS, {{"If-Match", this->revision()}, {"Content-Type", Request::MIMETYPE_BINARY}}}}, + content) + .json(); + this->_revision = res.at("etag"); + } catch (...) { + GDriveCloud::handleExceptions(std::current_exception(), this->path); + } +} +} // namespace CloudSync::gdrive diff --git a/lib/src/gdrive/GDriveFile.hpp b/lib/src/gdrive/GDriveFile.hpp new file mode 100644 index 0000000..68e9294 --- /dev/null +++ b/lib/src/gdrive/GDriveFile.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "CloudSync/File.hpp" + +namespace CloudSync::gdrive { +class GDriveFile : public File { + public: + GDriveFile( + const std::string &baseUrl, const std::string &resourceId, const std::string &dir, + const std::shared_ptr &request, const std::string &name, const std::string &revision) + : File(baseUrl, dir, request, name, revision), resourceId(resourceId){}; + void rm() override; + bool pollChange(bool longPoll = false) override; + bool supportsLongPoll() const override { + return false; + } + std::string read() const override; + void write(const std::string &content) override; + + private: + const std::string resourceId; +}; +} // namespace CloudSync::gdrive diff --git a/lib/src/nextcloud/NextcloudCloud.hpp b/lib/src/nextcloud/NextcloudCloud.hpp new file mode 100644 index 0000000..291e732 --- /dev/null +++ b/lib/src/nextcloud/NextcloudCloud.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "request/Request.hpp" +#include "pugixml.hpp" +#include "webdav/WebdavCloud.hpp" +#include + +using namespace pugi; +using namespace CloudSync::request; +using P = CloudSync::request::Request::ParameterType; + +namespace CloudSync::nextcloud { +class NextcloudCloud : public webdav::WebdavCloud { + public: + NextcloudCloud(const std::string &url, const std::shared_ptr &request) + : webdav::WebdavCloud(url, request) {} + + std::string getAuthorizeUrl() const override { + return this->baseUrl + "/index.php/login/flow"; + } + + std::shared_ptr root() const override { + return std::make_shared(this->baseUrl, "/remote.php/webdav", "/", this->request, ""); + } + + std::string getUserDisplayName() const override { + std::string userDisplayName; + try { + const auto responseXml = + this->request + ->GET( + this->baseUrl + "/ocs/v1.php/cloud/user", + {{P::HEADERS, {{"OCS-APIRequest", "true"}, {"Accept", Request::MIMETYPE_XML}}}}) + .xml(); + userDisplayName = responseXml->select_node("/ocs/data/display-name").node().child_value(); + } catch (...) { + NextcloudCloud::handleExceptions(std::current_exception(), ""); + } + return userDisplayName; + }; +}; +} // namespace CloudSync::nextcloud diff --git a/lib/src/onedrive/OneDriveCloud.hpp b/lib/src/onedrive/OneDriveCloud.hpp new file mode 100644 index 0000000..078542a --- /dev/null +++ b/lib/src/onedrive/OneDriveCloud.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include "CloudImpl.hpp" +#include "request/Request.hpp" +#include "request/Response.hpp" +#include "OneDriveDirectory.hpp" +#include + +using json = nlohmann::json; + +namespace CloudSync::onedrive { +class OneDriveCloud : public CloudImpl { + public: + OneDriveCloud(const std::string &drive, const std::shared_ptr &request) + : CloudImpl("https://graph.microsoft.com/v1.0/" + drive, request) {} + + std::string getAuthorizeUrl() const override { + return "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; + } + std::string getTokenUrl() const override { + return "https://login.microsoftonline.com/common/oauth2/v2.0/token"; + } + + std::shared_ptr root() const override { + return std::make_shared(this->baseUrl, "/", this->request, ""); + } + + static void handleExceptions(const std::exception_ptr &e, const std::string &resourcePath) { + try { + std::rethrow_exception(e); + } catch (request::Response::NotFound &e) { + throw Resource::NoSuchFileOrDirectory(resourcePath); + } catch (request::Response::Forbidden &e) { + throw Resource::PermissionDenied(resourcePath); + } catch (request::Response::Unauthorized &e) { + throw Cloud::AuthorizationFailed(); + } catch (request::Response::PreconditionFailed &e) { + try { + json errorJson = json::parse(e.data()); + const std::string errorMessage = errorJson.at("error").at("message"); + if (errorMessage == "ETag does not match current item's value") { + throw Resource::ResourceHasChanged(errorMessage); + } else { + throw Cloud::CommunicationError(errorMessage); + } + } catch (json::exception &e) { + throw Cloud::InvalidResponse(e.what()); + } + } catch (request::Response::ResponseException &e) { + throw Cloud::CommunicationError(e.what()); + } catch (json::exception &e) { + throw Cloud::InvalidResponse(e.what()); + } catch (request::Request::RequestException &e) { + throw Cloud::CommunicationError(e.what()); + } + } + + std::string getUserDisplayName() const override { + std::string userDisplayName; + try { + const auto getResponse = this->request->GET("https://graph.microsoft.com/v1.0/me").json(); + userDisplayName = getResponse.at("displayName"); + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), ""); + } + return userDisplayName; + }; +}; +} // namespace CloudSync::onedrive diff --git a/lib/src/onedrive/OneDriveDirectory.cpp b/lib/src/onedrive/OneDriveDirectory.cpp new file mode 100644 index 0000000..99ab726 --- /dev/null +++ b/lib/src/onedrive/OneDriveDirectory.cpp @@ -0,0 +1,151 @@ +#include "OneDriveDirectory.hpp" +#include "request/Request.hpp" +#include "OneDriveCloud.hpp" +#include "OneDriveFile.hpp" +#include +#include +#include + +using json = nlohmann::json; +using namespace CloudSync::request; +using P = Request::ParameterType; + +namespace CloudSync::onedrive { +std::vector> OneDriveDirectory::ls() const { + std::vector> resourceList; + try { + json responseJson = this->request->GET(this->apiResourcePath(this->path, true)).json(); + for (const auto value : responseJson.at("value")) { + resourceList.push_back(this->parseDriveItem(value)); + } + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), this->path); + } + return resourceList; +} + +std::shared_ptr OneDriveDirectory::cd(const std::string &path) const { + std::shared_ptr directory; + const auto resourcePath = this->newResourcePath(path); + try { + if (resourcePath == "/") { + // if its the root we dont need to run a query + directory = std::make_shared(this->_baseUrl, "/", this->request, ""); + } else { + json responseJson = this->request->GET(this->apiResourcePath(resourcePath, false)).json(); + directory = std::dynamic_pointer_cast(this->parseDriveItem(responseJson, "folder")); + } + + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), resourcePath); + } + return directory; +} + +void OneDriveDirectory::rmdir() const { + try { + if (this->path != "/") { + this->request->DELETE(this->_baseUrl + ":" + this->path); + } else { + throw PermissionDenied("deleting the root folder is not allowed"); + } + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), this->path); + } +} + +std::shared_ptr OneDriveDirectory::mkdir(const std::string &path) const { + std::shared_ptr newDirectory; + const auto resourcePath = std::filesystem::path(this->newResourcePath(path)); + const std::string newResourceBasePath = resourcePath.parent_path().generic_string(); + const std::string newDirectoryName = resourcePath.filename().string(); + try { + const auto responseJson = this->request + ->POST( + this->apiResourcePath(newResourceBasePath, true), + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_JSON}}}}, + json{{"name", newDirectoryName}, {"folder", json::object()}}.dump()) + .json(); + newDirectory = std::dynamic_pointer_cast(this->parseDriveItem(responseJson, "folder")); + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), resourcePath.generic_string()); + } + return newDirectory; +} + +std::shared_ptr OneDriveDirectory::touch(const std::string &path) const { + std::shared_ptr newFile; + const std::string resourcePath = this->newResourcePath(path); + try { + json responseJson = this->request + ->PUT( + this->_baseUrl + ":" + resourcePath + ":/content", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_BINARY}}}}, + "") + .json(); + newFile = std::dynamic_pointer_cast(this->parseDriveItem(responseJson, "file")); + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), resourcePath); + } + return newFile; +} + +std::shared_ptr OneDriveDirectory::file(const std::string &path) const { + std::shared_ptr file; + const std::string resourcePath = this->newResourcePath(path); + try { + json responseJson = this->request->GET(this->_baseUrl + ":" + resourcePath).json(); + file = std::dynamic_pointer_cast(this->parseDriveItem(responseJson, "file")); + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), resourcePath); + } + return file; +} + +std::shared_ptr OneDriveDirectory::parseDriveItem(const json &value, const std::string &expectedType) const { + std::shared_ptr resource; + const std::string name = value.at("name"); + // check if the returned item is the root item + if (value.find("root") != value.end()) { + resource = std::make_shared(this->_baseUrl, "/", this->request, ""); + } else { + const std::string rawResourcePath = value.at("parentReference").at("path"); + const auto splitPosition = rawResourcePath.find_first_of(':') + 1; + const std::string resourcePath = + rawResourcePath.substr(splitPosition, rawResourcePath.size() - splitPosition) + "/"; + if (value.find("file") != value.end() && (expectedType == "" || expectedType == "file")) { + const std::string etag = value["eTag"]; + resource = std::make_shared(this->_baseUrl, resourcePath + name, this->request, name, etag); + } else if (value.find("folder") != value.end() && (expectedType == "" || expectedType == "folder")) { + resource = std::make_shared(this->_baseUrl, resourcePath + name, this->request, name); + } else { + throw Cloud::CommunicationError("unexpected resource type"); + } + } + return resource; +} + +std::string OneDriveDirectory::apiResourcePath(const std::string &path, bool children) const { + std::string out = this->_baseUrl; + if (path == "/") { + if (children) { + out += "/children"; + } + } else { + out += ":" + path; + if (children) { + out += ":/children"; + } + } + return out; +} + +std::string OneDriveDirectory::newResourcePath(const std::string &path) const { + std::string normalizedPath = std::filesystem::path(this->path + "/" + path).lexically_normal().generic_string(); + // remove trailing slashes because onedrive won't accept them + while (normalizedPath.size() > 1 && normalizedPath.back() == '/') { + normalizedPath = normalizedPath.erase(normalizedPath.size() - 1); + } + return normalizedPath; +} +} // namespace CloudSync::onedrive diff --git a/lib/src/onedrive/OneDriveDirectory.hpp b/lib/src/onedrive/OneDriveDirectory.hpp new file mode 100644 index 0000000..d66f1e8 --- /dev/null +++ b/lib/src/onedrive/OneDriveDirectory.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "CloudSync/Directory.hpp" +#include + +using json = nlohmann::json; + +namespace CloudSync::onedrive { +class OneDriveDirectory : public Directory { + public: + OneDriveDirectory( + const std::string &baseUrl, const std::string &dir, const std::shared_ptr &request, + const std::string &name) + : Directory(baseUrl, dir, request, name){}; + std::vector> ls() const override; + std::shared_ptr cd(const std::string &path) const override; + void rmdir() const override; + std::shared_ptr mkdir(const std::string &path) const override; + std::shared_ptr touch(const std::string &path) const override; + std::shared_ptr file(const std::string &path) const override; + + private: + std::shared_ptr parseDriveItem(const json &value, const std::string &expectedType = "") const; + std::string apiResourcePath(const std::string &path, bool children = true) const; + + std::string newResourcePath(const std::string &path) const; +}; +} // namespace CloudSync::onedrive diff --git a/lib/src/onedrive/OneDriveFile.cpp b/lib/src/onedrive/OneDriveFile.cpp new file mode 100644 index 0000000..23f293c --- /dev/null +++ b/lib/src/onedrive/OneDriveFile.cpp @@ -0,0 +1,61 @@ +#include "OneDriveFile.hpp" +#include "request/Request.hpp" +#include "OneDriveCloud.hpp" +#include + +using json = nlohmann::json; +using namespace CloudSync::request; +using P = Request::ParameterType; + +namespace CloudSync::onedrive { +void OneDriveFile::rm() { + try { + this->request->DELETE(this->_baseUrl + ":" + this->path); + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), this->path); + } +} + +bool OneDriveFile::pollChange(bool longPoll) { + bool hasChanged = false; + try { + if (longPoll) { + throw Cloud::MethodNotSupportedError("Longpoll not supported"); + } else { + json responseJson = this->request->GET(this->_baseUrl + ":" + this->path).json(); + const std::string newRevision = responseJson.at("eTag"); + hasChanged = !(newRevision == this->_revision); + this->_revision = newRevision; + } + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), this->path); + } + return hasChanged; +} + +std::string OneDriveFile::read() const { + std::string fileContent; + try { + const auto getResult = this->request->GET(this->_baseUrl + ":" + this->path + ":/content"); + fileContent = getResult.data; + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), this->path); + } + return fileContent; +} + +void OneDriveFile::write(const std::string &content) { + try { + const auto responseJson = + this->request + ->PUT( + this->_baseUrl + ":" + this->path + ":/content", + {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_BINARY}, {"If-Match", this->revision()}}}}, + content) + .json(); + this->_revision = responseJson.at("eTag"); + } catch (...) { + OneDriveCloud::handleExceptions(std::current_exception(), this->path); + } +} +} // namespace CloudSync::onedrive diff --git a/lib/src/onedrive/OneDriveFile.hpp b/lib/src/onedrive/OneDriveFile.hpp new file mode 100644 index 0000000..fe507ee --- /dev/null +++ b/lib/src/onedrive/OneDriveFile.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "CloudSync/File.hpp" + +namespace CloudSync::onedrive { +class OneDriveFile : public File { + public: + OneDriveFile( + const std::string &baseUrl, const std::string &dir, const std::shared_ptr &request, + const std::string &name, const std::string &revision) + : File(baseUrl, dir, request, name, revision){}; + void rm() override; + bool pollChange(bool longPoll = false) override; + bool supportsLongPoll() const override { + return false; + } + std::string read() const override; + void write(const std::string &content) override; +}; +} // namespace CloudSync::onedrive diff --git a/lib/src/request/Request.cpp b/lib/src/request/Request.cpp new file mode 100644 index 0000000..d9f7558 --- /dev/null +++ b/lib/src/request/Request.cpp @@ -0,0 +1,93 @@ +#include "Request.hpp" +#include + +using namespace std::chrono; + +namespace CloudSync::request { +const std::string Request::MIMETYPE_XML = "application/xml"; +const std::string Request::MIMETYPE_JSON = "application/json"; +const std::string Request::MIMETYPE_BINARY = "application/octet-stream"; +const std::string Request::MIMETYPE_TEXT = "text/plain"; + +void Request::setOption(ConfigurationOption option, bool value) { + switch (option) { + case VERBOSE: + this->optionVerbose = value; + break; + case FOLLOW_REDIRECT: + this->optionFollowRedirects = value; + default: + break; + } +} + +void Request::refreshOAuth2TokenIfNeeded() { + if (this->accessToken.empty() || (!this->refreshToken.empty() && this->expires != system_clock::time_point(0s) && + this->expires <= system_clock::now())) { + // token expired and needs to be refreshed + + const std::string refreshToken = this->refreshToken; + // both refresh & access token need to be empty, otherwise the OAuth2 POST-request would attemt to authorize + // with OAuth2 + this->refreshToken = ""; + this->accessToken = ""; + try { + auto responseJson = + this->POST( + this->tokenRequestUrl, + {{HEADERS, {{"Accept", "application/json"}}}, + {POSTFIELDS, {{"grant_type", "refresh_token"}, {"refresh_token", refreshToken}}}}) + .json(); + this->accessToken = responseJson.at("access_token"); + // try to also extract a refresh token, if one has been provided + if (responseJson.find("refresh_token") != responseJson.end()) + this->refreshToken = responseJson["refresh_token"]; + if (responseJson.find("expires_in") != responseJson.end()) { + this->expires = system_clock::now() + seconds(responseJson["expires_in"]); + } else { + this->expires = system_clock::time_point(0s); + } + } catch (std::exception e) { + throw Response::Unauthorized("error with OAuth2 Authorization: " + std::string(e.what())); + } + } +} + +void Request::setBasicAuth(const std::string &username, const std::string &password) { + this->username = username; + this->password = password; + + // explicitly disable oauth2 + this->accessToken = ""; + this->refreshToken = ""; +} + +std::string Request::getUsername() { + return this->username; +} + +void Request::setOAuth2( + const std::string &token, const std::string &refreshToken, std::chrono::system_clock::time_point expires) { + this->accessToken = token; + this->refreshToken = refreshToken; + this->expires = expires; + // explicitly disable basicAuth + this->username = ""; + this->password = ""; +} + +void Request::setProxy(const std::string &proxyUrl, const std::string &proxyUser, const std::string &proxyPassword) { + this->proxyUrl = proxyUrl; + this->proxyUser = proxyUser; + this->proxyPassword = proxyPassword; +} + +void Request::setTokenRequestUrl(const std::string &tokenRequestUrl) { + this->tokenRequestUrl = tokenRequestUrl; +} + +std::string Request::getCurrentRefreshToken() const { + return this->refreshToken; +} + +} // namespace CloudSync::request diff --git a/lib/src/request/Request.hpp b/lib/src/request/Request.hpp new file mode 100644 index 0000000..4618932 --- /dev/null +++ b/lib/src/request/Request.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include "Response.hpp" +#include +#include +#include +#include + +using namespace std::literals::chrono_literals; + +namespace CloudSync::request { +class Request { + public: + virtual ~Request(){}; + + class RequestException : public std::runtime_error { + public: + RequestException(const std::string &what) : std::runtime_error(what){}; + }; + + // MARK: - new interface + enum ParameterType { HEADERS, QUERY_PARAMS, POSTFIELDS, MIME_POSTFIELDS, MIME_POSTFILES }; + enum ConfigurationOption { VERBOSE, FOLLOW_REDIRECT }; + + virtual Response request( + const std::string &verb, const std::string &url, + const std::unordered_map> ¶meters = {}, + const std::string &body = "") = 0; + + // MARK: - some aliases for known verbs + Response + GET(const std::string &url, + const std::unordered_map> ¶meters = {}) { + return this->request("GET", url, parameters); + } + Response POST( + const std::string &url, + const std::unordered_map> ¶meters = {}, + const std::string &body = "") { + return this->request("POST", url, parameters, body); + } + Response HEAD( + const std::string &url, + const std::unordered_map> ¶meters = {}) { + return this->request("HEAD", url, parameters); + } + Response + PUT(const std::string &url, + const std::unordered_map> ¶meters = {}, + const std::string &body = "") { + return this->request("PUT", url, parameters, body); + } + Response DELETE( + const std::string &url, + const std::unordered_map> ¶meters = {}) { + return this->request("DELETE", url, parameters); + } + Response MKCOL( + const std::string &url, + const std::unordered_map> ¶meters = {}, + const std::string &body = "") { + return this->request("MKCOL", url, parameters, body); + } + Response PROPFIND( + const std::string &url, + const std::unordered_map> ¶meters = {}, + const std::string &body = "") { + return this->request("PROPFIND", url, parameters, body); + } + void setBasicAuth(const std::string &username, const std::string &password); + std::string getUsername(); + virtual void setTokenRequestUrl(const std::string &tokenRequestUrl); + void virtual setProxy( + const std::string &proxyUrl, const std::string &proxyUser = "", const std::string &proxyPassword = ""); + virtual void setOAuth2( + const std::string &token, const std::string &refreshToken = "", + std::chrono::system_clock::time_point expires = std::chrono::system_clock::time_point(std::chrono::seconds(0))); + std::string getCurrentRefreshToken() const; + + void setOption(ConfigurationOption option, bool value); + + static const std::string MIMETYPE_XML; + static const std::string MIMETYPE_JSON; + static const std::string MIMETYPE_BINARY; + static const std::string MIMETYPE_TEXT; + + protected: + /** + * attemts to get a new OAuth2-Token + */ + void refreshOAuth2TokenIfNeeded(); + + bool optionVerbose = false; + bool optionFollowRedirects = false; + + // OAuth2 + std::string accessToken; + std::string refreshToken; + std::chrono::system_clock::time_point expires; + std::string tokenRequestUrl; + // Basic Auth + std::string username; + std::string password; + // Proxy + std::string proxyUrl; + std::string proxyUser; + std::string proxyPassword; +}; +} // namespace CloudSync::request diff --git a/lib/src/request/Response.hpp b/lib/src/request/Response.hpp new file mode 100644 index 0000000..05e0aef --- /dev/null +++ b/lib/src/request/Response.hpp @@ -0,0 +1,153 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace CloudSync::request { +class Response { + public: + // MARK: - exceptions + + class ParseError : public std::runtime_error { + public: + ParseError(const std::string &details) : std::runtime_error("response could not be parsed: " + details){}; + }; + + class ResponseException : public std::runtime_error { + public: + ResponseException(const std::string &data, int code, const std::string &what = "") + : std::runtime_error(std::to_string(code) + " " + what), _code(code), _data(data){}; + int code() const { + return _code; + } + std::string data() const { + return _data; + } + + private: + int _code; + std::string _data; + }; + + class ClientError : public ResponseException { + public: + ClientError(const std::string &data, int code, const std::string &what = "") + : ResponseException(data, code, what){}; + }; + + class ServerError : public ResponseException { + public: + ServerError(const std::string &data, int code, const std::string &what = "") + : ResponseException(data, code, what){}; + }; + + class BadRequest : public ClientError { + public: + BadRequest(const std::string &data) : ClientError(data, 400, "Bad Request\n" + data){}; + }; + + class Unauthorized : public ClientError { + public: + Unauthorized(const std::string &data) : ClientError(data, 401, "Unauthorized\n" + data){}; + }; + + class Forbidden : public ClientError { + public: + Forbidden(const std::string &data) : ClientError(data, 403, "Forbidden\n" + data){}; + }; + + class NotFound : public ClientError { + public: + NotFound(const std::string &data) : ClientError(data, 404, "Not Found\n" + data){}; + }; + + class MethodNotAllowed : public ClientError { + public: + MethodNotAllowed(const std::string &data) : ClientError(data, 405, "Method Not Allowed\n" + data){}; + }; + + class PreconditionFailed : public ClientError { + public: + PreconditionFailed(const std::string &data) : ClientError(data, 412, "Precondition Failed\n" + data){}; + }; + + class Conflict : public ClientError { + public: + Conflict(const std::string &data) : ClientError(data, 409, "Conflict\n" + data){}; + }; + + class InternalServerError : public ServerError { + public: + InternalServerError(const std::string &data) : ServerError(data, 500, "Internal Server Error\n" + data){}; + }; + + class ServiceUnavailable : public ServerError { + public: + ServiceUnavailable(const std::string &data) : ServerError(data, 503, "Service Unavailable\n" + data){}; + }; + + // MARK: - properties + const long code; + const std::string data; + const std::string contentType; + const std::unordered_map headers; + + nlohmann::json json() { + nlohmann::json responseJson; + try { + responseJson = nlohmann::json::parse(std::istringstream(this->data)); + } catch (...) { + throw ParseError("invalid json"); + } + return responseJson; + } + std::shared_ptr xml() { + const auto doc = std::make_shared(); + pugi::xml_parse_result result = doc->load_string(this->data.c_str()); + if (result) { + return doc; + } else { + throw ParseError("invalid xml"); + } + } + + // MARK: - constructor + Response( + long code, const std::string &data = "", const std::string &contentType = "", + const std::unordered_map &headers = {}) + : code(code), data(data), contentType(contentType), headers(headers) { + if (code >= 400) { + switch (code) { + case 400: + throw BadRequest(data); + case 401: + throw Unauthorized(data); + case 403: + throw Forbidden(data); + case 404: + throw NotFound(data); + case 405: + throw MethodNotAllowed(data); + case 409: + throw Conflict(data); + case 412: + throw PreconditionFailed(data); + case 500: + throw InternalServerError(data); + case 503: + throw ServiceUnavailable(data); + default: + if (code >= 500) { + throw ServerError(data, code); + } else { + throw ClientError(data, code); + } + } + } + }; +}; +} // namespace CloudSync::request diff --git a/lib/src/request/curl/CurlRequest.cpp b/lib/src/request/curl/CurlRequest.cpp new file mode 100644 index 0000000..26536ba --- /dev/null +++ b/lib/src/request/curl/CurlRequest.cpp @@ -0,0 +1,198 @@ +#include "CurlRequest.hpp" +#include +#include + +using json = nlohmann::json; + +using namespace std::chrono; +using namespace std::literals::chrono_literals; + +namespace CloudSync::request::curl { +CurlRequest::CurlRequest() { + this->curl = curl_easy_init(); +} + +static size_t WriteCallback(void *contents, size_t size, size_t nmemb, void *userp) { + ((std::string *)userp)->append((char *)contents, size * nmemb); + return size * nmemb; +} + +static size_t HeaderCallback(char *contents, size_t size, size_t nmemb, void *userp) { + const std::string header = std::string((char *)contents, size * nmemb); + const auto separatorPosition = header.find_first_of(':'); + if (separatorPosition != std::string::npos) { + std::string key = header.substr(0, separatorPosition); + std::transform(key.begin(), key.end(), key.begin(), ::tolower); + const std::string value = header.substr(separatorPosition + 1); + // trim away unwanted leading and trailing whitespaces + size_t start = value.find_first_not_of(CurlRequest::WHITESPACE); + size_t end = value.find_last_not_of(CurlRequest::WHITESPACE); + const std::string trimmedValue = value.substr(start, (end - start) + 1); + ((std::unordered_map *)userp)->insert({key, trimmedValue}); + } + return size * nmemb; +} + +const std::string CurlRequest::WHITESPACE = " \n\r"; + +Response CurlRequest::request( + const std::string &verb, const std::string &url, + const std::unordered_map> ¶meters, + const std::string &body) { + + if (!this->tokenRequestUrl.empty() && (!this->accessToken.empty() || !this->refreshToken.empty())) { + this->refreshOAuth2TokenIfNeeded(); + } + + struct curl_slist *headers = nullptr; + curl_mime *form = nullptr; + // enable redirects + if (this->optionFollowRedirects) + curl_easy_setopt(this->curl, CURLOPT_FOLLOWLOCATION, 1); + + // set verbose + if (this->optionVerbose) + curl_easy_setopt(this->curl, CURLOPT_VERBOSE, 1); + + // authorization + if (!this->username.empty() && !this->password.empty()) { + // Basic Auth + curl_easy_setopt(this->curl, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); + curl_easy_setopt(this->curl, CURLOPT_USERNAME, this->username.c_str()); + curl_easy_setopt(this->curl, CURLOPT_PASSWORD, this->password.c_str()); + } else if (!this->tokenRequestUrl.empty() && (!this->accessToken.empty() || !this->refreshToken.empty())) { + // OAuth2 + headers = curl_slist_append(headers, std::string("Authorization: Bearer " + this->accessToken).c_str()); + } + + // apply proxy + if (!this->proxyUrl.empty()) { + curl_easy_setopt(this->curl, CURLOPT_PROXY, this->proxyUrl.c_str()); + if (!this->proxyUser.empty() && !this->proxyPassword.empty()) { + const auto escapedProxyUser = + curl_easy_escape(this->curl, this->proxyUser.c_str(), this->proxyUser.length()); + const auto escapedProxyPassword = + curl_easy_escape(this->curl, this->proxyPassword.c_str(), this->proxyPassword.length()); + const auto proxyUserPwd = std::string(escapedProxyUser) + ":" + std::string(escapedProxyPassword); + curl_free(escapedProxyUser); + curl_free(escapedProxyPassword); + curl_easy_setopt(this->curl, CURLOPT_PROXYUSERPWD, proxyUserPwd.c_str()); + } + } + + // append query params to url + std::string finalUrl = url; + const auto queryParams = parameters.find(QUERY_PARAMS); + if (queryParams != parameters.end()) { + if (!queryParams->second.empty()) + finalUrl += "?"; + finalUrl += CurlRequest::urlEncodeParams(queryParams->second); + } + + // set url (including potential query params + curl_easy_setopt(this->curl, CURLOPT_URL, finalUrl.c_str()); + + // set headers + const auto headerParams = parameters.find(HEADERS); + if (headerParams != parameters.end()) { + for (const auto header : headerParams->second) { + headers = curl_slist_append(headers, (header.first + ": " + header.second).c_str()); + } + } + curl_easy_setopt(this->curl, CURLOPT_HTTPHEADER, headers); + + if (verb != "GET") { + if (verb == "HEAD") { + curl_easy_setopt(this->curl, CURLOPT_NOBODY, 1); + } else { + curl_easy_setopt(this->curl, CURLOPT_CUSTOMREQUEST, verb.c_str()); + } + if (!body.empty()) { + curl_easy_setopt(this->curl, CURLOPT_POSTFIELDS, body.c_str()); + } else { + const auto postfields = parameters.find(POSTFIELDS); + const auto mimePostfields = parameters.find(MIME_POSTFIELDS); + const auto mimePostfiles = parameters.find(MIME_POSTFILES); + if (postfields != parameters.end()) { + curl_easy_setopt( + this->curl, + CURLOPT_POSTFIELDS, + CurlRequest::urlEncodeParams(postfields->second).c_str()); + } else if (mimePostfields != parameters.end() || mimePostfiles != parameters.end()) { + form = curl_mime_init(this->curl); + + if (mimePostfields != parameters.end()) { + for (const auto &field : mimePostfields->second) { + curl_mimepart *mimePart = curl_mime_addpart(form); + curl_mime_name(mimePart, field.first.c_str()); + curl_mime_data(mimePart, field.second.c_str(), CURL_ZERO_TERMINATED); + } + } + if (mimePostfiles != parameters.end()) { + for (const auto &file : mimePostfiles->second) { + curl_mimepart *mimePart = curl_mime_addpart(form); + curl_mime_filename(mimePart, "upload"); + curl_mime_name(mimePart, file.first.c_str()); + curl_mime_data(mimePart, file.second.c_str(), CURL_ZERO_TERMINATED); + } + } + curl_easy_setopt(this->curl, CURLOPT_MIMEPOST, form); + } + } + } + // perform request + std::string responseReadBuffer; + std::unordered_map responseHeaders; + long responseCode; + char *responseContentType; + char errorbuffer[CURL_ERROR_SIZE]; + curl_easy_setopt(this->curl, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(this->curl, CURLOPT_WRITEDATA, &responseReadBuffer); + curl_easy_setopt(this->curl, CURLOPT_HEADERFUNCTION, HeaderCallback); + curl_easy_setopt(this->curl, CURLOPT_HEADERDATA, &responseHeaders); + curl_easy_setopt(this->curl, CURLOPT_ERRORBUFFER, errorbuffer); + const auto requestResult = curl_easy_perform(this->curl); + const auto responseCodeInfoResult = curl_easy_getinfo(this->curl, CURLINFO_RESPONSE_CODE, &responseCode); + const auto dataTypeInfoResult = curl_easy_getinfo(this->curl, CURLINFO_CONTENT_TYPE, &responseContentType); + + // cleanup after request + if (form != nullptr) + curl_mime_free(form); + curl_slist_free_all(headers); + + // return result + if (requestResult == CURLE_OK && responseCodeInfoResult == CURLE_OK && dataTypeInfoResult == CURLE_OK) { + // when the response has no body, responseContentType is a nullptr. This needs to be checked when + // transforming the char* to a string. + const std::string responseContentTypeString = responseContentType ? std::string(responseContentType) : ""; + const auto response = Response(responseCode, responseReadBuffer, responseContentTypeString, responseHeaders); + curl_easy_reset(this->curl); + return response; + } else { + const auto errorMessage = std::string(errorbuffer); + curl_easy_reset(this->curl); + throw RequestException(errorMessage); + } +} + +std::string CurlRequest::urlEncodeParams(const std::unordered_map ¶ms) const { + std::string result; + bool firstLoopIteration = true; + for (const auto param : params) { + if (firstLoopIteration) + firstLoopIteration = false; + else + result += "&"; + const auto key = curl_easy_escape(this->curl, param.first.c_str(), param.first.size()); + const auto value = curl_easy_escape(this->curl, param.second.c_str(), param.second.size()); + result += std::string(key) + "=" + std::string(value); + curl_free(key); + curl_free(value); + } + return result; +} + +CurlRequest::~CurlRequest() { + curl_easy_cleanup(this->curl); +} +} // namespace CloudSync::request::curl diff --git a/lib/src/request/curl/CurlRequest.hpp b/lib/src/request/curl/CurlRequest.hpp new file mode 100644 index 0000000..d4ab2d1 --- /dev/null +++ b/lib/src/request/curl/CurlRequest.hpp @@ -0,0 +1,23 @@ +#include "request/Request.hpp" +#include +#include + +namespace CloudSync::request::curl { +class CurlRequest : public Request { + public: + CurlRequest(); + ~CurlRequest() override; + + Response request( + const std::string &verb, const std::string &url, + const std::unordered_map> ¶meters, + const std::string &body) override; + + static const std::string WHITESPACE; + + private: + CURL *curl; + + std::string urlEncodeParams(const std::unordered_map ¶ms) const; +}; +} // namespace CloudSync::request::curl diff --git a/lib/src/webdav/WebdavCloud.hpp b/lib/src/webdav/WebdavCloud.hpp new file mode 100644 index 0000000..a1e4fdb --- /dev/null +++ b/lib/src/webdav/WebdavCloud.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "CloudImpl.hpp" +#include "CloudSync/Exceptions.hpp" +#include "request/Request.hpp" +#include "WebdavDirectory.hpp" +#include + +namespace CloudSync::webdav { +class WebdavCloud : public CloudImpl { + public: + WebdavCloud(const std::string &url, const std::shared_ptr &request) : CloudImpl(url, request) {} + + std::string getAuthorizeUrl() const override { + return ""; + } + + std::string getTokenUrl() const override { + return ""; + } + + virtual std::shared_ptr root() const override { + return std::make_shared(this->baseUrl, "", "/", this->request, ""); + } + + static void handleExceptions(const std::exception_ptr &e, const std::string &resourcePath) { + try { + std::rethrow_exception(e); + } catch (request::Response::NotFound &e) { + throw Resource::NoSuchFileOrDirectory(resourcePath); + } catch (request::Response::Forbidden &e) { + throw Resource::PermissionDenied(resourcePath); + } catch (request::Response::Unauthorized &e) { + throw Cloud::AuthorizationFailed(); + } catch (request::Response::ResponseException &e) { + throw Cloud::CommunicationError(e.what()); + } catch (request::Request::RequestException &e) { + throw Cloud::CommunicationError(e.what()); + } catch (request::Response::ParseError &e) { + throw Cloud::InvalidResponse(e.what()); + } + } + + std::string getUserDisplayName() const override { + return this->request->getUsername(); + }; +}; +} // namespace CloudSync::webdav diff --git a/lib/src/webdav/WebdavDirectory.cpp b/lib/src/webdav/WebdavDirectory.cpp new file mode 100644 index 0000000..7ec0883 --- /dev/null +++ b/lib/src/webdav/WebdavDirectory.cpp @@ -0,0 +1,209 @@ +#include "WebdavDirectory.hpp" +#include "request/Request.hpp" +#include "request/Response.hpp" +#include "WebdavCloud.hpp" +#include "WebdavFile.hpp" +#include "pugixml.hpp" +#include +#include +#include + +using namespace pugi; +using namespace CloudSync::request; +using P = Request::ParameterType; + +namespace CloudSync::webdav { +std::string WebdavDirectory::xmlQuery = "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + ""; + +std::vector> WebdavDirectory::ls() const { + std::vector> resources; + const auto resourcePath = this->requestUrl(""); + try { + const auto propfindResult = + this->request + ->PROPFIND( + resourcePath, + {{P::HEADERS, + {{"Depth", "1"}, {"Accept", Request::MIMETYPE_XML}, {"Content-Type", Request::MIMETYPE_XML}}}}, + xmlQuery) + .xml(); + resources = this->parseXmlResponse(propfindResult->root()); + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } + return resources; +} +std::shared_ptr WebdavDirectory::cd(const std::string &path) const { + const auto resourcePath = this->requestUrl(path); + std::shared_ptr directory; + try { + const auto propfindResponse = + this->request + ->PROPFIND( + resourcePath, + {{P::HEADERS, + {{"Depth", "0"}, {"Accept", Request::MIMETYPE_XML}, {"Content-Type", Request::MIMETYPE_XML}}}}, + WebdavDirectory::xmlQuery) + .xml(); + const auto resourceList = this->parseXmlResponse(propfindResponse->root()); + if (resourceList.size() == 1) { + directory = std::dynamic_pointer_cast(resourceList[0]); + } else { + throw Cloud::CommunicationError("cannot get resource description"); + } + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } + return directory; +} + +void WebdavDirectory::rmdir() const { + const std::string resourcePath = this->requestUrl(""); + try { + this->request->DELETE(resourcePath); + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } +} +std::shared_ptr WebdavDirectory::mkdir(const std::string &name) const { + const auto resourcePath = this->requestUrl(name); + std::shared_ptr directory; + try { + this->request->MKCOL(resourcePath); + const auto propfindResponse = + this->request + ->PROPFIND( + resourcePath, + {{P::HEADERS, + {{"Depth", "0"}, {"Accept", Request::MIMETYPE_XML}, {"Content-Type", Request::MIMETYPE_XML}}}}, + WebdavDirectory::xmlQuery) + .xml(); + const auto resourceList = this->parseXmlResponse(propfindResponse->root()); + if (resourceList.size() == 1) { + directory = std::dynamic_pointer_cast(resourceList[0]); + } else { + throw Cloud::CommunicationError("cannot get resource description"); + } + + } catch (Response::Conflict e) { + throw NoSuchFileOrDirectory(resourcePath); + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } + return directory; +} + +std::shared_ptr WebdavDirectory::touch(const std::string &name) const { + const auto resourcePath = this->requestUrl(name); + std::shared_ptr file; + try { + this->request->PUT(resourcePath, {{P::HEADERS, {{"Content-Type", Request::MIMETYPE_BINARY}}}}, ""); + file = this->file(name); + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } + return file; +} + +std::shared_ptr WebdavDirectory::file(const std::string &name) const { + const auto resourcePath = this->requestUrl(name); + std::shared_ptr file; + try { + const auto propfindResponse = + this->request + ->PROPFIND( + resourcePath, + {{P::HEADERS, + {{"Depth", "0"}, {"Accept", Request::MIMETYPE_XML}, {"Content-Type", Request::MIMETYPE_XML}}}}, + WebdavDirectory::xmlQuery) + .xml(); + const auto resourceList = this->parseXmlResponse(propfindResponse->root()); + if (resourceList.size() == 1) { + file = std::dynamic_pointer_cast(resourceList[0]); + } else { + throw Cloud::CommunicationError("cannot get metadata for file"); + } + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } + return file; +} + +std::vector> WebdavDirectory::parseXmlResponse(const xml_node &response) const { + std::vector> resources; + const auto responseNodeSets = response.select_nodes("/*[local-name()='multistatus']/*[local-name()='response']"); + for (const auto responseNodeSet : responseNodeSets) { + const auto responseNode = responseNodeSet.node(); + + // read path from xml + std::string resourceHref = responseNode.select_node("./*[local-name()='href']").node().child_value(); + // remove path offset from the beginning of the path + resourceHref.erase(0, this->dirOffset.size()); + // remove any trailing slashes because webdav returns folders with + // trailing slashes. + WebdavDirectory::removeTrailingSlashes(resourceHref); + if (resourceHref != this->path) { + // parse the href as path so the filename/foldername can be + // extracted + const auto resourcePath = std::filesystem::path(resourceHref); + std::shared_ptr resource; + // if the collection node exists, we can be sure this is a + // directory, else it must be a file + const auto filename = resourcePath.filename().string(); + if (responseNode.select_node("./*[local-name()='propstat']" + "/*[local-name()='prop']" + "/*[local-name()='resourcetype']" + "/*[local-name()='collection']")) { + + resource = std::make_shared( + this->_baseUrl, + this->dirOffset, + resourceHref, + this->request, + filename); + } else if ( + const auto revision = responseNode + .select_node("./*[local-name()='propstat']" + "/*[local-name()='prop']" + "/*[local-name()='getetag']") + .node() + .child_value()) { + resource = std::make_shared( + this->_baseUrl + this->dirOffset, + resourceHref, + this->request, + filename, + revision); + } + resources.push_back(resource); + } + } + return resources; +} + +std::string WebdavDirectory::requestUrl(const std::string &path) const { + // normalize path + std::string normalizedPath = std::filesystem::path(this->path + "/" + path).lexically_normal().generic_string(); + // remove any trailing slashes, because we cannot be sure if the user adds + // them or not + WebdavDirectory::removeTrailingSlashes(normalizedPath); + std::stringstream result; + result << this->_baseUrl << this->dirOffset << normalizedPath; + return result.str(); +} + +void WebdavDirectory::removeTrailingSlashes(std::string &path) { + while (path.size() > 1 && path.back() == '/') { + path.erase(path.size() - 1); + } +} +} // namespace CloudSync::webdav diff --git a/lib/src/webdav/WebdavDirectory.hpp b/lib/src/webdav/WebdavDirectory.hpp new file mode 100644 index 0000000..de65941 --- /dev/null +++ b/lib/src/webdav/WebdavDirectory.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "CloudSync/Directory.hpp" +#include "request/Response.hpp" + +namespace CloudSync::webdav { +class WebdavDirectory : public Directory { + public: + WebdavDirectory( + const std::string &baseUrl, const std::string &dirOffset, const std::string &dir, + const std::shared_ptr &request, const std::string &name) + : Directory(baseUrl, dir, request, name), dirOffset(dirOffset){}; + std::vector> ls() const override; + std::shared_ptr cd(const std::string &path) const override; + void rmdir() const override; + std::shared_ptr mkdir(const std::string &path) const override; + std::shared_ptr touch(const std::string &path) const override; + std::shared_ptr file(const std::string &path) const override; + + private: + static std::string xmlQuery; + const std::string dirOffset; + std::vector> parseXmlResponse(const pugi::xml_node &response) const; + /// Appends `path` to the current path and returns the result. + std::string requestUrl(const std::string &path) const; + static void removeTrailingSlashes(std::string &path); +}; +} // namespace CloudSync::webdav diff --git a/lib/src/webdav/WebdavFile.cpp b/lib/src/webdav/WebdavFile.cpp new file mode 100644 index 0000000..6f3f163 --- /dev/null +++ b/lib/src/webdav/WebdavFile.cpp @@ -0,0 +1,90 @@ +#include "WebdavFile.hpp" +#include "request/Request.hpp" +#include "WebdavCloud.hpp" +#include "pugixml.hpp" + +using namespace CloudSync::request; +using namespace pugi; + +using P = Request::ParameterType; + +namespace CloudSync::webdav { + +std::string WebdavFile::xmlQuery = "\n" + "\n" + " \n" + " \n" + " \n" + ""; + +void WebdavFile::rm() { + const std::string resourcePath = this->_baseUrl + this->path; + try { + this->request->DELETE(resourcePath); + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } +} + +bool WebdavFile::pollChange(bool longPoll) { + bool hasChanged = false; + const std::string resourcePath = this->_baseUrl + this->path; + try { + if (longPoll) { + throw Cloud::MethodNotSupportedError("Longpoll not supported"); + } else { + const auto propfindResponse = this->request + ->PROPFIND( + resourcePath, + {{P::HEADERS, + {{"Depth", "0"}, + {"Accept", Request::MIMETYPE_XML}, + {"Content-Type", Request::MIMETYPE_XML}}}}, + WebdavFile::xmlQuery) + .xml(); + const std::string newRevision = propfindResponse + ->select_node("/*[local-name()='multistatus']" + "/*[local-name()='response']" + "/*[local-name()='propstat']" + "/*[local-name()='prop']" + "/*[local-name()='getetag']") + .node() + .child_value(); + if (newRevision != "") { + if (this->revision() != newRevision) { + hasChanged = true; + this->_revision = newRevision; + } + } else { + throw Cloud::InvalidResponse("reading XML failed: missing required 'getetag' property"); + } + } + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } + return hasChanged; +} + +std::string WebdavFile::read() const { + std::string data; + const std::string resourcePath = this->_baseUrl + this->path; + try { + data = this->request->GET(resourcePath).data; + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } + return data; +} +void WebdavFile::write(const std::string &input) { + const std::string resourcePath = this->_baseUrl + this->path; + try { + const auto putResult = this->request->PUT( + resourcePath, + {{P::HEADERS, {{"If-Match", this->revision()}, {"Content-Type", Request::MIMETYPE_BINARY}}}}, + input); + this->_revision = putResult.headers.at("etag"); + } catch (...) { + WebdavCloud::handleExceptions(std::current_exception(), resourcePath); + } +} +} // namespace CloudSync::webdav diff --git a/lib/src/webdav/WebdavFile.hpp b/lib/src/webdav/WebdavFile.hpp new file mode 100644 index 0000000..6e7cbb3 --- /dev/null +++ b/lib/src/webdav/WebdavFile.hpp @@ -0,0 +1,22 @@ +#include "CloudSync/File.hpp" +#include "request/Request.hpp" + +namespace CloudSync::webdav { +class WebdavFile : public File { + public: + WebdavFile( + const std::string &baseUrl, const std::string &dir, const std::shared_ptr &request, + const std::string &name, const std::string &revision) + : File(baseUrl, dir, request, name, revision){}; + void rm() override; + bool pollChange(bool longPoll = false) override; + bool supportsLongPoll() const override { + return false; + } + std::string read() const override; + void write(const std::string &content) override; + + private: + static std::string xmlQuery; +}; +} // namespace CloudSync::webdav diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt deleted file mode 100644 index 7f14f9e..0000000 --- a/src/CMakeLists.txt +++ /dev/null @@ -1,33 +0,0 @@ -cmake_minimum_required(VERSION 3.15) - -project(MyLibrary) - -# dependencies -find_package(nlohmann_json REQUIRED) - -# library definition -add_library(MyLibrary - example.cpp - include/MyLibrary/example.hpp) -target_compile_features(MyLibrary PUBLIC cxx_std_17) -target_include_directories(MyLibrary - PUBLIC - $ - $ -) -target_link_libraries(MyLibrary - PUBLIC - nlohmann_json::nlohmann_json -) - -# library installation -install(TARGETS MyLibrary EXPORT MyLibraryTargets - LIBRARY DESTINATION lib - ARCHIVE DESTINATION lib - RUNTIME DESTINATION bin - INCLUDES DESTINATION include) - -install(DIRECTORY include/MyLibrary - DESTINATION include) - -add_library(MyLibrary::MyLibrary ALIAS MyLibrary) diff --git a/src/example.cpp b/src/example.cpp deleted file mode 100644 index d050b44..0000000 --- a/src/example.cpp +++ /dev/null @@ -1,10 +0,0 @@ -#include "MyLibrary/example.hpp" -#include - -using namespace MyLibrary; -using json = nlohmann::json; - -std::string Example::test(const std::string& test) { - json testJson = {{"hello", test}}; - return testJson.dump(4); -} \ No newline at end of file diff --git a/src/include/MyLibrary/example.hpp b/src/include/MyLibrary/example.hpp deleted file mode 100644 index 5c9f267..0000000 --- a/src/include/MyLibrary/example.hpp +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once -#include - -namespace MyLibrary { - -/** - * Example class - */ -class Example { -public: - std::string test(const std::string& test); -}; - -} - diff --git a/test/BoxCloudTest.cpp b/test/BoxCloudTest.cpp new file mode 100644 index 0000000..7e8ef11 --- /dev/null +++ b/test/BoxCloudTest.cpp @@ -0,0 +1,39 @@ +#include "box/BoxCloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; + +SCENARIO("BoxCloud", "[cloud][box]") { + INIT_REQUEST() + GIVEN("a box cloud instance") { + const auto cloud = std::make_shared(request); + AND_GIVEN("a request that returns a user account description") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"name", "John Doe"}}.dump(), "application/json")); + + WHEN("calling getUserDisplayName()") { + const auto name = cloud->getUserDisplayName(); + THEN("John Doe should be returned") { + REQUIRE(name == "John Doe"); + } + THEN("the box users endpoint should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/users/me"); + } + } + } + WHEN("calling root()") { + const auto directory = cloud->root(); + THEN("the root directory is returned") { + REQUIRE(directory->name == ""); + REQUIRE(directory->path == "/"); + } + } + } +} diff --git a/test/BoxDirectoryTest.cpp b/test/BoxDirectoryTest.cpp new file mode 100644 index 0000000..41aab77 --- /dev/null +++ b/test/BoxDirectoryTest.cpp @@ -0,0 +1,306 @@ +#include "box/BoxDirectory.hpp" +#include "CloudSync/Cloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::box; +using json = nlohmann::json; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +SCENARIO("BoxDirectory", "[directory][box]") { + INIT_REQUEST(); + + GIVEN("a box root directory") { + const auto directory = std::make_shared("0", "0", "/", request, ""); + + THEN("the working dir should be '/'") { + REQUIRE(directory->pwd() == "/"); + } + + AND_GIVEN("a GET request that returns a valid directory listing") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"total_count", 2}, + {"limit", 1000}, + {"offset", 0}, + {"order", {{{"by", "type"}, {"direction", "ASC"}}}}, + {"entries", + {{{"id", "1234"}, {"etag", "1"}, {"type", "file"}, {"sequence_id", "3"}, {"name", "test.txt"}}, + {{"id", "1235"}, + {"etag", "42"}, + {"type", "folder"}, + {"sequence_id", "4"}, + {"name", "testfolder"}}}}} + .dump(), + "application/json")); + + WHEN("calling ls()") { + auto list = directory->ls(); + THEN("the box items endpoint should be called with the id of the root folder (0)") { + Verify(Method((*requestMock), request)).Once(); + REQUIRE(requestRecording.front().verb == "GET"); + REQUIRE(requestRecording.front().url == "https://api.box.com/2.0/folders/0/items"); + } + + THEN("a list of all resources contained in the directory should be returned") { + REQUIRE(list.size() == 2); + REQUIRE(list[0]->name == "test.txt"); + REQUIRE(list[0]->path == "/test.txt"); + REQUIRE(list[1]->name == "testfolder"); + REQUIRE(list[1]->path == "/testfolder"); + } + } + + WHEN("calling cd(testfolder), cd(testfolder/), cd(/testfolder/), cd(/subfolder/../testfolder)") { + std::string path = GENERATE( + as{}, + "testfolder", + "testfolder/", + "/testfolder/", + "/subfolder/../testfolder"); + const auto newDir = directory->cd(path); + + THEN("the box items endpoint should be called with the id of the root folder (0)") { + Verify(Method((*requestMock), request)).Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/folders/0/items"); + } + + THEN("the sub-folder called 'testfolder' should be returned") { + REQUIRE(newDir->name == "testfolder"); + REQUIRE(newDir->path == "/testfolder"); + } + } + + WHEN("calling file(test.txt)") { + const auto file = directory->file("test.txt"); + THEN("the box items endpoint should be called with the id of the root folder (0)") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/folders/0/items"); + } + + THEN("the file called test.txt should be returned") { + REQUIRE(file->name == "test.txt"); + REQUIRE(file->path == "/test.txt"); + } + } + } + AND_GIVEN("a POST request that returns a new file description (in a list)") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"total_count", 1}, + {"entries", + {{ + {"id", "12345"}, + {"etag", "1234"}, + {"type", "file"}, + {"sequence_id", "3"}, + {"name", "newfile.txt"} + // a lot more stuff comes here normally + }}}} + .dump(), + "application/json")); + WHEN("calling touch(newfile.txt)") { + directory->touch("newfile.txt"); + + THEN("the box upload endpoint should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://upload.box.com/api/2.0/files/content"); + REQUIRE_REQUEST( + 0, + parameters.at(P::MIME_POSTFIELDS).at("attributes") == + "{\"name\":\"newfile.txt\",\"parent\":{\"id\":\"0\"}}"); + REQUIRE_REQUEST(0, parameters.at(P::MIME_POSTFILES).at("file") == ""); + } + } + } + AND_GIVEN("another GET request that returns a valid directory listing twice") { + WHEN_REQUEST() + .RESPOND(request::Response( + 200, + json{ + {"total_count", 2}, + {"limit", 1000}, + {"offset", 0}, + {"order", {{{"by", "type"}, {"direction", "ASC"}}}}, + {"entries", + {{{"id", "1234"}, + {"etag", "1"}, + {"type", "file"}, + {"sequence_id", "3"}, + {"name", "testfile.txt"}}, + {{"id", "1235"}, + {"etag", "423"}, + {"type", "folder"}, + {"sequence_id", "4"}, + {"name", "testfolder"}}}}} + .dump(), + "application/json")) + .RESPOND(request::Response( + 200, + json{ + {"total_count", 1}, + {"limit", 1000}, + {"offset", 0}, + {"order", {{{"by", "type"}, {"direction", "ASC"}}}}, + {"entries", + {{{"id", "1236"}, + {"etag", "423"}, + {"type", "folder"}, + {"sequence_id", "4"}, + {"name", "testfolder2"}}}}} + .dump(), + "application/json")); + WHEN("calling cd (testfolder/testfolder2)") { + const auto newDir = directory->cd("testfolder/testfolder2"); + + THEN("the box items endpoint should be called for '/' first, then for 'testfolder'") { + REQUIRE_REQUEST_CALLED().Twice(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/folders/0/items"); + REQUIRE_REQUEST(1, verb == "GET"); + REQUIRE_REQUEST(1, url == "https://api.box.com/2.0/folders/1235/items"); + } + + THEN("the sub-sub folder 'testfolder2' should be returned") { + REQUIRE(newDir->name == "testfolder2"); + REQUIRE(newDir->path == "/testfolder/testfolder2"); + } + } + } + + AND_GIVEN("a POST request that returns a valid folder description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"id", "1234"}, + {"etag", "1"}, + {"type", "folder"}, + {"sequence_id", "3"}, + {"name", "newfolder"}, + } + .dump(), + "application/json")); + + WHEN("calling mkdir(newfolder)") { + const auto newFolder = directory->mkdir("newfolder"); + + THEN("the box post-folders endpoint should be called with a correct json payload") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/folders"); + REQUIRE_REQUEST(0, body == "{\"name\":\"newfolder\",\"parent\":{\"id\":\"0\"}}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + THEN("the new folder resource should be returned") { + REQUIRE(newFolder->name == "newfolder"); + REQUIRE(newFolder->path == "/newfolder"); + } + } + } + + AND_GIVEN("a request that first returns a folder listing and then a folder description") { + WHEN_REQUEST() + .RESPOND(request::Response( + 200, + json{ + {"total_count", 1}, + {"limit", 1000}, + {"offset", 0}, + {"order", {{{"by", "type"}, {"direction", "ASC"}}}}, + {"entries", + {{{"id", "1236"}, + {"etag", "423"}, + {"type", "folder"}, + {"sequence_id", "4"}, + {"name", "testfolder"}}}}} + .dump(), + "application/json")) + .RESPOND(request::Response( + 200, + json{ + {"id", "1234"}, + {"etag", "1"}, + {"type", "folder"}, + {"sequence_id", "3"}, + {"name", "newfolder"}, + } + .dump(), + "application/json")); + + WHEN("calling mkdir (testfolder/newfolder)") { + const auto newDir = directory->mkdir("testfolder/newfolder"); + + THEN("the box items endpoint should be called on the root folder") { + REQUIRE_REQUEST_CALLED().Twice(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/folders/0/items"); + } + THEN("the box endpoint to create a new folder should be called") { + REQUIRE_REQUEST_CALLED().Twice(); + REQUIRE_REQUEST(1, verb == "POST"); + REQUIRE_REQUEST(1, url == "https://api.box.com/2.0/folders"); + REQUIRE_REQUEST( + 1, + body == "{\"name\":\"newfolder\",\"parent\":{" + "\"id\":\"1236\"}}"); + REQUIRE_REQUEST(1, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + THEN("the newly created folder should be returned") { + REQUIRE(newDir->name == "newfolder"); + REQUIRE(newDir->path == "/testfolder/newfolder"); + } + } + } + WHEN("calling rmdir()") { + THEN("a PermissionDenied Exception should be thrown") { + REQUIRE_THROWS_AS(directory->rmdir(), CloudSync::Resource::PermissionDenied); + } + } + } + GIVEN("a box (non-root) directory") { + const auto directory = std::make_shared("1234", "0", "/somefolder", request, "somefolder"); + + AND_GIVEN("a request that returns 204") { + WHEN_REQUEST().RESPOND(request::Response(204)); + + WHEN("calling rmdir()") { + directory->rmdir(); + + THEN("the box delete request should be called with the folder id") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "DELETE"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/folders/1234"); + } + } + } + + AND_GIVEN("a GET request that returns the root folder description") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"id", "0"}}.dump(), "application/json")); + WHEN("calling cd(..)") { + const auto root = directory->cd(".."); + THEN("the box folder endpoint should be called for the root folder (0)") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/folders/0"); + } + + THEN("the root directory should be returned") { + REQUIRE(root->name == ""); + REQUIRE(root->path == "/"); + } + } + } + } +} diff --git a/test/BoxFileTest.cpp b/test/BoxFileTest.cpp new file mode 100644 index 0000000..316afd0 --- /dev/null +++ b/test/BoxFileTest.cpp @@ -0,0 +1,119 @@ +#include "box/BoxFile.hpp" +#include "CloudSync/Cloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::box; +using json = nlohmann::json; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +SCENARIO("BoxFile", "[file][box]") { + INIT_REQUEST(); + GIVEN("a box file instance") { + const auto file = std::make_shared("1234", "/folder/filename.txt", request, "filename.txt", "abcd"); + + AND_GIVEN("a DELETE request that returns 204") { + WHEN_REQUEST().RESPOND(request::Response(204)); + + WHEN("calling rm()") { + file->rm(); + THEN("the box file-delete endpoint should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "DELETE"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/files/1234"); + } + } + } + + AND_GIVEN("a request that returns 404") { + WHEN_REQUEST().Throw(request::Response::NotFound("")); + + WHEN("calling rm()") { + THEN("a NoSuchFileOrDirectory Exception should be thrown") { + REQUIRE_THROWS_AS(file->rm(), CloudSync::Resource::NoSuchFileOrDirectory); + } + } + } + + AND_GIVEN("a request that returns the file content") { + WHEN_REQUEST().RESPOND(request::Response(200, "filecontent", "text/plain")); + WHEN("calling read()") { + const auto fileContent = file->read(); + + THEN("the box download endpoint should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/files/1234/content"); + } + + THEN("the file-content should be returned") { + REQUIRE(fileContent == "filecontent"); + } + } + } + + AND_GIVEN("a POST request that returns a valid file description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"total_count", 1}, + {"entries", + {{ + {"id", "1234"}, + {"etag", "newetag"}, + // a lot more stuff that doesn't matter here + }}}} + .dump(), + "application/json")); + + WHEN("calling write(newcontent)") { + file->write("newcontent"); + + THEN("the box file update endpoint should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://upload.box.com/api/2.0/files/1234/content"); + REQUIRE_REQUEST(0, parameters.at(P::MIME_POSTFIELDS).at("attributes") == "{}"); + REQUIRE_REQUEST(0, parameters.at(P::MIME_POSTFILES).at("file") == "newcontent"); + } + + THEN("the revision should be updated") { + REQUIRE(file->revision() == "newetag"); + } + } + } + AND_GIVEN("a request that returns a changed file description") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"etag", "newetag"}}.dump(), "application/json")); + + WHEN("calling pollChange()") { + const bool hasChanged = file->pollChange(); + THEN("a GET request should be made to ask for the file metadata") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://api.box.com/2.0/files/1234"); + } + THEN("true should be returned and the new revision should be set") { + REQUIRE(hasChanged); + REQUIRE(file->revision() == "newetag"); + } + } + } + AND_GIVEN("a POST request that returns the same file revision") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"etag", "abcd"}}.dump(), "application/json")); + WHEN("calling pollChange()") { + const bool hasChanged = file->pollChange(); + THEN("false should be returned") { + REQUIRE(hasChanged == false); + } + } + } + } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index fc00718..e219284 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,18 +1,45 @@ cmake_minimum_required(VERSION 3.15) -project(MyLibraryTest) +project(CloudSyncTest) find_package(Catch2 MODULE REQUIRED) +find_package(fakeit MODULE REQUIRED) -add_executable(MyLibraryTest - mylibrarytest.cpp +include(Catch) + +add_executable(CloudSyncTest + main.cpp + OAuth2CredentialsTest.cpp + UsernamePasswordCredentialsTest.cpp + WebdavCloudTest.cpp + NextcloudCloudTest.cpp + WebdavDirectoryTest.cpp + WebdavFileTest.cpp + DropboxCloudTest.cpp + DropboxDirectoryTest.cpp + DropboxFileTest.cpp + BoxCloudTest.cpp + BoxDirectoryTest.cpp + BoxFileTest.cpp + OneDriveCloudTest.cpp + OneDriveDirectoryTest.cpp + OneDriveFileTest.cpp + GDriveCloudTest.cpp + GDriveDirectoryTest.cpp + GDriveFileTest.cpp + CloudFactoryTest.cpp ) -target_link_libraries(MyLibraryTest +target_link_libraries(CloudSyncTest Catch2::Catch2 - MyLibrary::MyLibrary + fakeit::fakeit + CloudSync::CloudSync +) + +set_target_properties (CloudSyncTest PROPERTIES + FOLDER CloudSync ) -add_test(MyLibraryTest MyLibraryTest) +catch_discover_tests(CloudSyncTest) -target_compile_features(MyLibraryTest PRIVATE cxx_std_17) \ No newline at end of file +target_compile_features(CloudSyncTest PRIVATE cxx_std_17) \ No newline at end of file diff --git a/test/CloudFactoryTest.cpp b/test/CloudFactoryTest.cpp new file mode 100644 index 0000000..d3deb6d --- /dev/null +++ b/test/CloudFactoryTest.cpp @@ -0,0 +1,66 @@ +#include "CloudSync/CloudFactory.hpp" +#include "request/Request.hpp" +#include "box/BoxCloud.hpp" +#include "dropbox/DropboxCloud.hpp" +#include "gdrive/GDriveCloud.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include "nextcloud/NextcloudCloud.hpp" +#include "onedrive/OneDriveCloud.hpp" +#include "webdav/WebdavCloud.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; + +SCENARIO("CloudFactory", "[cloud][factory]") { + INIT_REQUEST(); + GIVEN("a cloudFactory instance") { + auto cloudFactory = std::make_shared(); + WHEN("calling dropbox()") { + const auto cloud = cloudFactory->dropbox(); + THEN("a dropbox cloud instance should be returned") { + REQUIRE(std::dynamic_pointer_cast(cloud)); + } + } + WHEN("calling box()") { + const auto cloud = cloudFactory->box(); + THEN("a box cloud instance should be returned") { + REQUIRE(std::dynamic_pointer_cast(cloud)); + } + } + WHEN("calling nextcloud(\"my.cloud\")") { + const auto cloud = cloudFactory->nextcloud("my.cloud"); + THEN("a nextcloud cloud instance should be returned") { + REQUIRE(std::dynamic_pointer_cast(cloud)); + } + THEN("the nextcloud instance should hold the correct url") { + REQUIRE(cloud->getBaseUrl() == "my.cloud"); + } + } + WHEN("calling onedrive()") { + const auto cloud = cloudFactory->onedrive(); + THEN("a onedrive cloud instance should be returned") { + REQUIRE(std::dynamic_pointer_cast(cloud)); + } + } + WHEN("calling gdrive()") { + const auto cloud = cloudFactory->gdrive(); + THEN("a gdrive cloud instance should be returned") { + REQUIRE(std::dynamic_pointer_cast(cloud)); + } + } + WHEN("calling webdav(\"my.cloud\")") { + const auto cloud = cloudFactory->webdav("my.cloud"); + THEN("a webdav cloud instance should be returned") { + REQUIRE(std::dynamic_pointer_cast(cloud)); + } + THEN("the webdav instance should hold the correct url") { + REQUIRE(cloud->getBaseUrl() == "my.cloud"); + } + } + } +} diff --git a/test/DropboxCloudTest.cpp b/test/DropboxCloudTest.cpp new file mode 100644 index 0000000..6a471bd --- /dev/null +++ b/test/DropboxCloudTest.cpp @@ -0,0 +1,39 @@ +#include "dropbox/DropboxCloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; + +SCENARIO("DropboxCloud", "[cloud][dropbox]") { + INIT_REQUEST(); + GIVEN("a dropbox cloud instance") { + const auto cloud = std::make_shared(request); + AND_GIVEN("a request that returns the users account information") { + WHEN_REQUEST().RESPOND( + request::Response(200, json{{"name", {{"display_name", "John Doe"}}}}.dump(), "application/json")); + WHEN("calling getUserDisplayName()") { + const auto name = cloud->getUserDisplayName(); + THEN("John Doe should be returned") { + REQUIRE(name == "John Doe"); + } + THEN("a request to get_current_account should be made") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://api.dropboxapi.com/2/users/get_current_account"); + } + } + } + WHEN("calling root()") { + const auto directory = cloud->root(); + THEN("the root directory is returned") { + REQUIRE(directory->name == ""); + REQUIRE(directory->path == "/"); + } + } + } +} diff --git a/test/DropboxDirectoryTest.cpp b/test/DropboxDirectoryTest.cpp new file mode 100644 index 0000000..23083f7 --- /dev/null +++ b/test/DropboxDirectoryTest.cpp @@ -0,0 +1,317 @@ +#include "dropbox/DropboxDirectory.hpp" +#include "CloudSync/Cloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::dropbox; +using json = nlohmann::json; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +SCENARIO("DropboxDirectory", "[directory][dropbox]") { + INIT_REQUEST(); + + GIVEN("a dropbox root directory") { + + const auto directory = std::make_shared("/", request, ""); + THEN("the working dir should be '/'") { + REQUIRE(directory->pwd() == "/"); + } + + AND_GIVEN("a request that returns a valid dropbox directory listing") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"entries", + {{{".tag", "folder"}, + {"name", "test"}, + {"path_lower", "/test"}, + {"path_display", "/test"}, + {"id", "id:O4T7biRqN_EAAAAAAAANRg"}}, + {{".tag", "file"}, + {"name", "test.txt"}, + {"path_lower", "/test.txt"}, + {"path_display", "/test.txt"}, + {"id", "id:O4T7biRqN_EAAAAAAAANR"}, + {"client_modified", "2020-01-29T21:00:50Z"}, + {"server_modified", "2020-01-29T21:00:50Z"}, + {"rev", "0159d4da2a6fc2100000001a2504350"}, + {"size", 11048}, + {"is_downloadable", true}, + {"content_hash", + "a59943f6fe5e4cbc25366a1c412b4278bc74984353265c2160" + "607a073c9cb540"}}}}, + {"cursor", + "AAEunngK5i6uSxwrSlvTngxpzli3qKoVouhB8LtojjN9gA-" + "NVeH6gNRP0wE2rMRCjkS-h7b9Ryqoqv8PFg5LXhFGVUIMyWQWokb_" + "dc5v8UbzqsULaX-" + "QSg6TgwPVSRl2Alv8tMPzLBWdrjIG3XJdmuzeFx7mp9kcA-" + "3qBm0QDswM1g"}, + {"has_more", false}} + .dump(), + "application/json")); + + WHEN("calling ls (list) on the directory") { + auto list = directory->ls(); + + THEN("the dropbox list_folder endpoint should be called with a json payload pointing to the root " + "folder") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://api.dropboxapi.com/2/files/list_folder"); + REQUIRE_REQUEST(0, body == "{\"path\":\"\",\"recursive\":false}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + THEN("a list of all resources contained in the directory should be returned") { + REQUIRE(list.size() == 2); + REQUIRE(list[0]->name == "test"); + REQUIRE(list[0]->path == "/test"); + REQUIRE(list[1]->name == "test.txt"); + REQUIRE(list[1]->path == "/test.txt"); + } + } + } + AND_GIVEN("a POST request that returns a valid folder metadata description (with .tag)") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {".tag", "folder"}, + {"name", "test"}, + {"path_lower", "/test"}, + {"path_display", "/test"}, + {"id", "id:O4T7biRqN_EAAAAAAAANRg"}} + .dump(), + "application/json")); + + WHEN("calling cd(test), cd(test/), cd(/test/), cd(/test/more/..)") { + std::string path = GENERATE(as{}, "test", "test/", "/test/", "/test/more/.."); + auto newDir = directory->cd(path); + THEN("the dropbox metadata endpoint should be called with a " + "json payload pointing to the desired folder") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://api.dropboxapi.com/2/files/get_metadata"); + REQUIRE_REQUEST(0, body == "{\"path\":\"/test\"}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + THEN("a dir called 'test' should be returned") { + REQUIRE(newDir->name == "test"); + REQUIRE(newDir->pwd() == "/test"); + } + } + WHEN("calling file(test)") { + THEN("a NoSuchFileOrDirectory Exception should be thrown " + "because a folder description is returned") { + REQUIRE_THROWS_AS(directory->file("test"), CloudSync::Resource::NoSuchFileOrDirectory); + } + } + } + AND_GIVEN("a POST request that returns a valid folder metadata description (without .tag)") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{{"metadata", + {{"name", "test"}, + {"path_lower", "/test"}, + {"path_display", "/test"}, + {"id", "id:O4T7biRqN_EAAAAAAAANRg"}}}} + .dump(), + "application/json")); + + WHEN("calling mkdir(test) on the current directory") { + auto newDir = directory->mkdir("test"); + THEN("the dropbox create_folder_v2 endpoint should be called " + "with a json payload describing the new folder name") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://api.dropboxapi.com/2/files/create_folder_v2"); + REQUIRE_REQUEST(0, body == "{\"path\":\"/test\"}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + THEN("a new dir called 'test' should be returned") { + REQUIRE(newDir->name == "test"); + } + } + } + AND_GIVEN("a POST request that returns a valid file metadata description (without .tag)") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"name", "test.txt"}, + {"path_lower", "/test.txt"}, + {"path_display", "/test.txt"}, + {"id", "id:O4T7biRqN_EAAAAAAAANR"}, + {"client_modified", "2020-01-29T21:00:50Z"}, + {"server_modified", "2020-01-29T21:00:50Z"}, + {"rev", "0159d4da2a6fc2100000001a2504350"}, + {"size", 11048}, + {"is_downloadable", true}, + {"content_hash", "a59943f6fe5e4cbc25366a1c412b4278bc74984353265c2160607a073c9cb540"}} + .dump(), + "application/json")); + + WHEN("calling touch(test.txt)") { + auto newFile = directory->touch("test.txt"); + + THEN("the dropbox upload endpoint should be called with the arg param pointing to the new file and " + "with an empty request body") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://content.dropboxapi.com/2/files/upload"); + REQUIRE_REQUEST(0, parameters.at(P::QUERY_PARAMS).at("arg") == "{\"path\":\"/test.txt\"}"); + REQUIRE_REQUEST(0, body == ""); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_BINARY); + } + THEN("a new file called 'test.txt' should be returned") { + REQUIRE(newFile->name == "test.txt"); + } + } + } + AND_GIVEN("a POST request that returns a valid file metadata description (with .tag)") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {".tag", "file"}, + {"name", "test.txt"}, + {"path_lower", "/test.txt"}, + {"path_display", "/test.txt"}, + {"id", "id:O4T7biRqN_EAAAAAAAANR"}, + {"client_modified", "2020-01-29T21:00:50Z"}, + {"server_modified", "2020-01-29T21:00:50Z"}, + {"rev", "0159d4da2a6fc2100000001a2504350"}, + {"size", 11048}, + {"is_downloadable", true}, + {"content_hash", "a59943f6fe5e4cbc25366a1c412b4278bc74984353265c2160607a073c9cb540"}} + .dump(), + "application/json")); + + WHEN("calling file(test.txt)") { + auto file = directory->file("test.txt"); + THEN("the dropbox metadata endpoint should be called with a json payload pointing to the file") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://api.dropboxapi.com/2/files/get_metadata"); + REQUIRE_REQUEST(0, body == "{\"path\":\"/test.txt\"}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + THEN("a file called 'test.txt' should be returned") { + REQUIRE(file->name == "test.txt"); + } + } + } + WHEN("deleting the current directory") { + THEN("a PermissionDenied exception should be thrown because the root dir cannot be deleted") { + REQUIRE_THROWS_AS(directory->rmdir(), CloudSync::Resource::PermissionDenied); + } + } + AND_GIVEN("a POST request that returns an invalid (non-json) response") { + WHEN_REQUEST().RESPOND(request::Response(200, "un-parseable", "text/plain")); + WHEN("calling ls") { + THEN("an InvalidResponse exception should be thrown") { + REQUIRE_THROWS_AS(directory->ls(), CloudSync::Cloud::InvalidResponse); + } + } + WHEN("calling cd(test)") { + THEN("an InvalidResponse exception should be thrown") { + REQUIRE_THROWS_AS(directory->cd("test"), CloudSync::Cloud::InvalidResponse); + } + } + WHEN("calling mkdir(test)") { + THEN("an InvalidResponse exception should be thrown") { + REQUIRE_THROWS_AS(directory->mkdir("test"), CloudSync::Cloud::InvalidResponse); + } + } + WHEN("calling touch(test.txt)") { + THEN("an InvalidResponse exception should be thrown") { + REQUIRE_THROWS_AS(directory->touch("test.txt"), CloudSync::Cloud::InvalidResponse); + } + } + WHEN("calling file(test.txt)") { + THEN("an InvalidResponse exception should be thrown") { + REQUIRE_THROWS_AS(directory->file("test.txt"), CloudSync::Cloud::InvalidResponse); + } + } + } + AND_GIVEN("a POST request that fails with a 409 (Conflict) response because a resource could not be found") { + WHEN_REQUEST().Throw(request::Response::Conflict(json{ + {"error_summary", "path/not_found/.."}, + {"error", {{".tag", "path"}, {"path", {{".tag", "not_found"}}}}}} + .dump())); + WHEN("calling ls") { + THEN("a NoSuchFileOrDirectory exception should be thrown") { + REQUIRE_THROWS_AS(directory->ls(), CloudSync::Resource::NoSuchFileOrDirectory); + } + } + WHEN("calling cd(test)") { + THEN("a NoSuchFileOrDirectory exception should be thrown") { + REQUIRE_THROWS_AS(directory->cd("test"), CloudSync::Resource::NoSuchFileOrDirectory); + } + } + WHEN("calling file(test.txt)") { + THEN("a NoSuchFileOrDirectory exception should be thrown") { + REQUIRE_THROWS_AS(directory->file("test.txt"), CloudSync::Resource::NoSuchFileOrDirectory); + } + } + } + AND_GIVEN("a POST request that fails with a 409 (Conflict) that cannot be parsed") { + WHEN_REQUEST().Throw(request::Response::Conflict("un-parseable")); + WHEN("calling ls") { + THEN("an InvalidResponse Exception should be thrown") { + REQUIRE_THROWS_AS(directory->ls(), CloudSync::Cloud::InvalidResponse); + } + } + WHEN("calling cd(test)") { + THEN("an InvalidResponse Exception should be thrown") { + REQUIRE_THROWS_AS(directory->cd("test"), CloudSync::Cloud::InvalidResponse); + } + } + WHEN("calling file(test.txt)") { + THEN("an InvalidResponse Exception should be thrown") { + REQUIRE_THROWS_AS(directory->file("test.txt"), CloudSync::Cloud::InvalidResponse); + } + } + } + } + GIVEN("a dropbox (non-root) directory") { + const auto directory = std::make_shared("/test", request, "test"); + AND_GIVEN("a POST request that returns 200") { + WHEN_REQUEST().RESPOND(request::Response(200, "", "")); + WHEN("deleting the current directory") { + directory->rmdir(); + THEN("the dropbox delete_v2 endpoint should be called with a " + "json pointing to the to be deleted file") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://api.dropboxapi.com/2/files/delete_v2"); + REQUIRE_REQUEST(0, body == "{\"path\":\"/test\"}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + } + } + AND_GIVEN("a request that fails with a 409 (Conflict) response because a path lookup has failed") { + WHEN_REQUEST().Throw(request::Response::Conflict(json{ + {"error_summary", "path_lookup/not_found/.."}, + {"error", {{".tag", "path_lookup"}, {"path_lookup", {{".tag", "not_found"}}}}}} + .dump())); + WHEN("calling rmdir") { + THEN("a NoSuchFileOrDirectory exception should be thrown") { + REQUIRE_THROWS_AS(directory->rmdir(), CloudSync::Resource::NoSuchFileOrDirectory); + } + } + } + AND_GIVEN("a request that fails with a 409 (Conflict) that cannot be parsed") { + WHEN_REQUEST().Throw(request::Response::Conflict("un-parseable")); + WHEN("calling rmdir") { + THEN("an InvalidResponse Exception should be thrown") { + REQUIRE_THROWS_AS(directory->rmdir(), CloudSync::Cloud::InvalidResponse); + } + } + } + } +} diff --git a/test/DropboxFileTest.cpp b/test/DropboxFileTest.cpp new file mode 100644 index 0000000..984a3be --- /dev/null +++ b/test/DropboxFileTest.cpp @@ -0,0 +1,115 @@ +#include "dropbox/DropboxFile.hpp" +#include "CloudSync/Cloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::dropbox; +using json = nlohmann::json; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +SCENARIO("DropboxFile", "[file][dropbox]") { + INIT_REQUEST(); + + GIVEN("a DropboxFile instance") { + const auto file = std::make_shared("/test.txt", request, "test.txt", "revision-id"); + AND_GIVEN("a request that returns 200") { + WHEN_REQUEST().RESPOND(request::Response(200, "", "")); + WHEN("the file is deleted") { + file->rm(); + THEN("the dropbox delete endpoint should be called with a json payload pointing to the file") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://api.dropboxapi.com/2/files/delete_v2"); + REQUIRE_REQUEST(0, body == "{\"path\":\"/test.txt\"}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + } + } + AND_GIVEN("a request that returns binary data") { + WHEN_REQUEST().RESPOND(request::Response(200, "binary-data-010101", "application/octet-stream")); + + WHEN("the file is read") { + std::string content = file->read(); + THEN("the dropbox download endpoint should be called with an arg parameter pointing to the file") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://content.dropboxapi.com/2/files/download"); + REQUIRE_REQUEST(0, body == ""); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_TEXT); + REQUIRE_REQUEST(0, parameters.at(P::QUERY_PARAMS).at("arg") == "{\"path\":\"/test.txt\"}"); + } + THEN("the file-content should be returned") { + REQUIRE(content == "binary-data-010101"); + } + } + } + AND_GIVEN("a POST request that returns an updated file metadata description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"name", "test.txt"}, + {"path_lower", "/test.txt"}, + {"path_display", "/test.txt"}, + {"id", "id:O4T7biRqN_EAAAAAAAANWg"}, + {"client_modified", "2020-02-11T20:39:07Z"}, + {"server_modified", "2020-02-11T20:39:07Z"}, + {"rev", "newrevision"}, + {"size", 5}, + {"is_downloadable", true}, + {"content_hash", "dcf37e3729a3e1063df9ebb284d642b7ae3a5fe80337bf25b5751d6a1d6bd97f"}} + .dump(), + "application/json")); + + WHEN("writing to the file") { + const std::string newContent = "awesome new content"; + file->write(newContent); + THEN("the dropbox upload endpoint should have been called with an arg param pointing to the file and " + "requesting an update") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://content.dropboxapi.com/2/files/upload"); + REQUIRE_REQUEST(0, body == newContent); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_BINARY); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("arg") == + "{\"mode\":{\".tag\":\"update\",\"update\":\"revision-id\"},\"path\":\"/test.txt\"}"); + } + THEN("the revision of the file should have been updated") { + REQUIRE(file->revision() == "newrevision"); + } + } + WHEN("calling pollChange()") { + const bool hasChanged = file->pollChange(); + THEN("the dropbox get_metadata endpoint should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == "https://api.dropboxapi.com/2/files/get_metadata"); + REQUIRE_REQUEST(0, body == "{\"path\":\"/test.txt\"}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + THEN("true should be returned and the revision should be updated") { + REQUIRE(hasChanged); + REQUIRE(file->revision() == "newrevision"); + } + } + } + AND_GIVEN("a POST request that returns a file description with the same revision") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"rev", "revision-id"}}.dump(), "application/json")); + WHEN("calling pollChange()") { + const bool hasChanged = file->pollChange(); + THEN("false should be returned") { + REQUIRE(hasChanged == false); + } + } + } + } +} diff --git a/test/GDriveCloudTest.cpp b/test/GDriveCloudTest.cpp new file mode 100644 index 0000000..32d27d8 --- /dev/null +++ b/test/GDriveCloudTest.cpp @@ -0,0 +1,39 @@ +#include "gdrive/GDriveCloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; + +SCENARIO("GDriveCloud", "[cloud][gdrive]") { + INIT_REQUEST(); + GIVEN("a google drive cloud instance") { + const auto cloud = std::make_shared("root", request); + AND_GIVEN("a request that returns a user description") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"name", "john doe"}}.dump(), "application/json")); + + WHEN("calling getUserDisplayName()") { + const auto name = cloud->getUserDisplayName(); + THEN("john doe should be returned") { + REQUIRE(name == "john doe"); + } + THEN("a request the google oauth2 userinfo endpoint should be made") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://www.googleapis.com/userinfo/v2/me"); + } + } + } + WHEN("calling root()") { + const auto directory = cloud->root(); + THEN("the root directory is returned") { + REQUIRE(directory->name == ""); + REQUIRE(directory->path == "/"); + } + } + } +} diff --git a/test/GDriveDirectoryTest.cpp b/test/GDriveDirectoryTest.cpp new file mode 100644 index 0000000..1f5d591 --- /dev/null +++ b/test/GDriveDirectoryTest.cpp @@ -0,0 +1,304 @@ +#include "gdrive/GDriveDirectory.hpp" +#include "CloudSync/Cloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::gdrive; +using json = nlohmann::json; +using namespace CloudSync::request; +using P = Request::ParameterType; + +SCENARIO("GDriveDirectory", "[directory][gdrive]") { + INIT_REQUEST() + const std::string BASE_URL = "https://www.googleapis.com/drive/v2"; + + GIVEN("a google drive root directory") { + const auto directory = std::make_shared(BASE_URL, "root", "root", "root", "/", request, ""); + + THEN("the working dir should be '/'") { + REQUIRE(directory->pwd() == "/"); + } + + AND_GIVEN("a request that returns a valid folder listing") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{{"items", + { + {{"kind", "drive#file"}, + {"id", "1dInfWIELU8Hc1sP_bsGnVa44DgBpNybI"}, + {"title", "testfolder"}, + {"mimeType", "application/vnd.google-apps.folder"}, + {"etag", "2"}, + {"parents", {{{"id", "0ANREbljg-Vs3Uk9PVA"}, {"isRoot", true}}}}}, + {{"kind", "drive#file"}, + {"id", "2iInfWIELU3tc1sPhbsGwVa34DgBpN3Es"}, + {"title", "test.txt"}, + {"mimeType", "text/plain"}, + {"etag", "1"}, + {"parents", {{{"id", "0ANREbljg-Vs3Uk9PVA"}, {"isRoot", true}}}}}, + }}} + .dump(), + "application/json")); + + WHEN("calling ls()") { + const auto resourceList = directory->ls(); + THEN("the google drive files endpoint should be called with the correct query") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == BASE_URL + "/files"); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("q") == "'root' in parents and trashed = false"); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("fields") == + "items(kind,id,title,mimeType,etag,parents(id,isRoot))"); + } + THEN("a list of 2 resources should be returned") { + REQUIRE(resourceList.size() == 2); + REQUIRE(resourceList[0]->name == "testfolder"); + REQUIRE(resourceList[0]->path == "/testfolder"); + const auto file = std::dynamic_pointer_cast(resourceList[1]); + REQUIRE(file->name == "test.txt"); + REQUIRE(file->path == "/test.txt"); + REQUIRE(file->revision() == "1"); + } + } + } + AND_GIVEN("a GET request that throws 401 unauthorized") { + WHEN_REQUEST().Throw(request::Response::Unauthorized("")); + + WHEN("calling ls()") { + THEN("an Cloud::AuthorizationFailed exception should be thrown") { + REQUIRE_THROWS_AS(directory->ls(), Cloud::AuthorizationFailed); + } + } + } + AND_GIVEN("a GET request that a folder description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{{"items", + {{{"kind", "drive#file"}, + {"id", "1dInfWIELU8Hc1sP_bsGnVa44DgBpNybI"}, + {"title", "testfolder"}, + {"mimeType", "application/vnd.google-apps.folder"}, + {"etag", "2"}, + {"parents", {{{"id", "0ANREbljg-Vs3Uk9PVA"}, {"isRoot", true}}}}}}}} + .dump(), + "application/json")); + + WHEN("calling cd(testfolder), cd(/testfolder/), cd(/subfolder/../testfolder)") { + const std::string path = + GENERATE(as{}, "testfolder", "/testfolder/", "/subfolder/../testfolder"); + const auto newDir = directory->cd(path); + THEN("the google drive files endpoint should be called with the correct query") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == BASE_URL + "/files"); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("q") == + "'root' in parents and title = 'testfolder' and trashed = false"); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("fields") == + "items(kind,id,title,mimeType,etag,parents(id,isRoot))"); + } + THEN("the desired folder should be returned") { + REQUIRE(newDir->name == "testfolder"); + REQUIRE(newDir->path == "/testfolder"); + } + } + } + AND_GIVEN("a request that returns a file description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{{"items", + {{{"kind", "drive#file"}, + {"id", "52InfWIELUrHc1sPlbstnVa44DgBpNyb5"}, + {"title", "test.txt"}, + {"mimeType", "text/plain"}, + {"etag", "2"}, + {"parents", {{{"id", "0ANREbljg-Vs3Uk9PVA"}, {"isRoot", true}}}}}}}} + .dump(), + "application/json")); + + WHEN("calling file(test.txt)") { + const auto file = directory->file("test.txt"); + THEN("the google drive files endpoint should be called with the correct query parameters") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == BASE_URL + "/files"); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("q") == + "'root' in parents and title = 'test.txt' and trashed = false"); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("fields") == + "items(kind,id,title,mimeType,etag,parents(id,isRoot))"); + } + THEN("the desired file should be returned") { + REQUIRE(file->name == "test.txt"); + REQUIRE(file->path == "/test.txt"); + REQUIRE(file->revision() == "2"); + } + } + } + AND_GIVEN("a POST request that returns a new folder resource description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"kind", "drive#file"}, + {"id", "folderId"}, + {"title", "newfolder"}, + {"mimeType", "application/vnd.google-apps.folder"}, + {"etag", "1"}, + {"parents", {{{"id", "0ANREbljg-Vs3Uk9PVA"}, {"isRoot", true}}}}} + .dump(), + "application/json")); + + WHEN("calling mkdir(newfolder)") { + const auto newDir = directory->mkdir("newfolder"); + THEN("the endpoint for creating a new resource should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == BASE_URL + "/files"); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("fields") == + "kind,id,title,mimeType,etag,parents(id,isRoot)"); + REQUIRE_REQUEST( + 0, + body == "{\"mimeType\":\"application/vnd.google-apps.folder\"," + "\"parents\":[{\"id\":\"root\"}],\"title\":\"newfolder\"}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + THEN("the new folder should be returned") { + REQUIRE(newDir->name == "newfolder"); + REQUIRE(newDir->path == "/newfolder"); + } + } + } + AND_GIVEN("a request that returns a new file resource description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"kind", "drive#file"}, + {"id", "folderId"}, + {"title", "newfile.txt"}, + {"mimeType", "text/plain"}, + {"etag", "1"}, + {"parents", {{{"id", "0ANREbljg-Vs3Uk9PVA"}, {"isRoot", true}}}}} + .dump(), + "application/json")); + + WHEN("calling touch(newfile.txt)") { + const auto newFile = directory->touch("newfile.txt"); + THEN("the endpoint for creating a new resource should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST(0, url == BASE_URL + "/files"); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("fields") == + "kind,id,title,mimeType,etag,parents(id,isRoot)"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + REQUIRE_REQUEST( + 0, + body == "{\"mimeType\":\"text/plain\"," + "\"parents\":[{\"id\":\"root\"}],\"title\":\"newfile.txt\"}"); + } + THEN("the new file resource should be returned") { + REQUIRE(newFile->name == "newfile.txt"); + REQUIRE(newFile->path == "/newfile.txt"); + } + } + } + WHEN("calling rmdir()") { + THEN("a PermissionDenied should be called (deleting root is not allowed") { + REQUIRE_THROWS_AS(directory->rmdir(), CloudSync::Resource::PermissionDenied); + } + } + } + GIVEN("a google drive directory (non root)") { + const auto directory = std::make_shared( + BASE_URL, + "root", + "resourceId", + "parentId", + "/test/folder", + request, + "folder"); + + AND_GIVEN("a request that returns a valid folder description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"kind", "drive#file"}, + {"id", "1dInfWIELU8Hc1sP_bsGnVa44DgBpNybI"}, + {"title", "test"}, + {"mimeType", "application/vnd.google-apps.folder"}, + {"etag", "2"}, + {"parents", {{{"id", "0ANREbljg-Vs3Uk9PVA"}, {"isRoot", false}}}}} + .dump(), + "application/json")); + + WHEN("calling cd(..)") { + const auto parentDir = directory->cd(".."); + THEN("a google drive lists request should be done with a query for the parent folder id") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == BASE_URL + "/files/parentId"); + REQUIRE_REQUEST( + 0, + parameters.at(P::QUERY_PARAMS).at("fields") == + "kind,id,title,mimeType,etag,parents(id,isRoot)"); + } + + THEN("the parent directory should be returned") { + REQUIRE(parentDir->name == "test"); + REQUIRE(parentDir->path == "/test"); + } + } + WHEN("calling cd(../../") { + const auto rootDir = directory->cd("../../"); + THEN("only one request should be made. The root cannot be queried") { + REQUIRE_REQUEST_CALLED().Once(); + } + THEN("the root dir should be returned") { + REQUIRE(rootDir->name == ""); + REQUIRE(rootDir->path == "/"); + } + } + } + AND_GIVEN("a request that returns 204") { + WHEN_REQUEST().RESPOND(request::Response(204)); + + WHEN("calling rmdir()") { + directory->rmdir(); + THEN("a google drive delete call should be made") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "DELETE"); + REQUIRE_REQUEST(0, url == BASE_URL + "/files/resourceId"); + } + } + } + AND_GIVEN("a request that throws 404 Not Found") { + WHEN_REQUEST().Throw(request::Response::NotFound( + json{{"error", {{"errors", {{{"domain", "global"}, {"reason", "notFound"}}}}}}}.dump())); + WHEN("calling rmdir()") { + THEN("a NoSuchFileOrDirectory Exception should be thrown") { + REQUIRE_THROWS_AS(directory->rmdir(), Resource::NoSuchFileOrDirectory); + } + } + } + } +} diff --git a/test/GDriveFileTest.cpp b/test/GDriveFileTest.cpp new file mode 100644 index 0000000..1d9812f --- /dev/null +++ b/test/GDriveFileTest.cpp @@ -0,0 +1,108 @@ +#include "gdrive/GDriveFile.hpp" +#include "CloudSync/Cloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::gdrive; +using json = nlohmann::json; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +SCENARIO("GDriveFile", "[file][gdrive]") { + INIT_REQUEST(); + const std::string BASE_URL = "https://www.googleapis.com/drive/v3"; + + GIVEN("a google drive file") { + const auto file = std::make_shared(BASE_URL, "fileId", "/test.txt", request, "test.txt", "2"); + AND_GIVEN("a request that returns 204") { + WHEN_REQUEST().RESPOND(request::Response(204)); + + WHEN("calling rm()") { + file->rm(); + THEN("the file endpoint should be called for deletion") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "DELETE"); + REQUIRE_REQUEST(0, url == BASE_URL + "/files/fileId"); + } + } + } + AND_GIVEN("a request that returns a download link and a GET request that returns the file content") { + WHEN_REQUEST() + .RESPOND(request::Response(200, json{{"downloadUrl", "downloadlink"}}.dump(), "application/json")) + .RESPOND(request::Response(200, "file content", "text/plain")); + + WHEN("calling read()") { + const auto content = file->read(); + THEN("a request to get the download link and a request to download the actual content should be made") { + REQUIRE_REQUEST_CALLED().Twice(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == BASE_URL + "/files/fileId"); + REQUIRE_REQUEST(0, parameters.at(P::QUERY_PARAMS).at("fields") == "downloadUrl"); + REQUIRE_REQUEST(1, verb == "GET"); + REQUIRE_REQUEST(1, url == "downloadlink"); + } + THEN("the files content should be returned") { + REQUIRE(content == "file content"); + } + } + } + AND_GIVEN("a request that returns a new etag") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"etag", "newetag"}}.dump(), "application/json")); + + WHEN("calling write(somenewcontent)") { + file->write("somenewcontent"); + THEN("a PUT request should be made on the file upload endpoint") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PUT"); + REQUIRE_REQUEST(0, url == "https://www.googleapis.com/upload/drive/v2/files/fileId"); + REQUIRE_REQUEST(0, parameters.at(P::QUERY_PARAMS).at("uploadType") == "media"); + REQUIRE_REQUEST(0, parameters.at(P::QUERY_PARAMS).at("fields") == "etag"); + REQUIRE_REQUEST(0, body == "somenewcontent"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("If-Match") == "2"); + } + THEN("the file revision should be updated") { + REQUIRE(file->revision() == "newetag"); + } + } + } + AND_GIVEN("a request that returns the current etag (no change)") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"etag", "2"}}.dump(), "application/json")); + + WHEN("calling pollChange()") { + bool hasChanged = file->pollChange(); + THEN("the etag field should be requested for the file") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://www.googleapis.com/drive/v3/files/fileId"); + REQUIRE_REQUEST(0, parameters.at(P::QUERY_PARAMS).at("fields") == "etag"); + } + THEN("false should be returned") { + REQUIRE(hasChanged == false); + } + THEN("the revision should be the same") { + REQUIRE(file->revision() == "2"); + } + } + } + AND_GIVEN("a request that returns a new etag (new revision)") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"etag", "thisisanewetag"}}.dump(), "application/json")); + + WHEN("calling pollChange()") { + bool hasChanged = file->pollChange(); + THEN("true should be returned") { + REQUIRE(hasChanged == true); + } + THEN("the files revision should be updated") { + REQUIRE(file->revision() == "thisisanewetag"); + } + } + } + } +} diff --git a/test/NextcloudCloudTest.cpp b/test/NextcloudCloudTest.cpp new file mode 100644 index 0000000..8f0aab4 --- /dev/null +++ b/test/NextcloudCloudTest.cpp @@ -0,0 +1,64 @@ +#include "nextcloud/NextcloudCloud.hpp" +#include "CloudSync/Proxy.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +SCENARIO("NextcloudCloud", "[cloud][nextcloud]") { + INIT_REQUEST(); + GIVEN("a nextcloud cloud instance") { + const auto cloud = std::make_shared("http://nextcloud", request); + AND_GIVEN("a request that returns a ocs user description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + "" + "" + " " + " John Doe" + " " + "", + "application/xml")); + + WHEN("calling getUserDisplayName()") { + std::string name = cloud->getUserDisplayName(); + THEN("the display name should be set to 'John Doe'") { + REQUIRE(name == "John Doe"); + } + THEN("a request to the OCS endpoint should be made") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "http://nextcloud/ocs/v1.php/cloud/user"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("OCS-APIRequest") == "true"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Accept") == Request::MIMETYPE_XML); + } + } + } + WHEN("calling getBaseUrl") { + const auto baseUrl = cloud->getBaseUrl(); + THEN("it should return the correct base url") { + REQUIRE(baseUrl == "http://nextcloud"); + } + } + WHEN("calling getTokenUrl") { + const std::string result = cloud->getTokenUrl(); + THEN("an empty string should be returned") { + REQUIRE(result.empty()); + } + } + WHEN("calling root()") { + const auto rootDir = cloud->root(); + THEN("a root dir with path '/' & name '' should be returned") { + REQUIRE(rootDir->name == ""); + REQUIRE(rootDir->path == "/"); + } + } + } +} diff --git a/test/OAuth2CredentialsTest.cpp b/test/OAuth2CredentialsTest.cpp new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/OAuth2CredentialsTest.cpp @@ -0,0 +1 @@ + diff --git a/test/OneDriveCloudTest.cpp b/test/OneDriveCloudTest.cpp new file mode 100644 index 0000000..de03186 --- /dev/null +++ b/test/OneDriveCloudTest.cpp @@ -0,0 +1,40 @@ +#include "onedrive/OneDriveCloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; + +SCENARIO("OneDriveCloud", "[cloud][onedrive]") { + INIT_REQUEST(); + GIVEN("a onedrive cloud instance") { + const auto cloud = std::make_shared("me/drive/root", request); + AND_GIVEN("a request that returns a graph user account description") { + WHEN_REQUEST().RESPOND( + request::Response(200, json{{"displayName", "John Doe"}}.dump(), "application/json")); + + WHEN("calling getUserDisplayName()") { + const auto name = cloud->getUserDisplayName(); + THEN("'John Doe' should be returned") { + REQUIRE(name == "John Doe"); + } + THEN("the Graph user endpoint should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://graph.microsoft.com/v1.0/me"); + } + } + } + WHEN("calling root()") { + const auto directory = cloud->root(); + THEN("the root directory is returned") { + REQUIRE(directory->name == ""); + REQUIRE(directory->path == "/"); + } + } + } +} diff --git a/test/OneDriveDirectoryTest.cpp b/test/OneDriveDirectoryTest.cpp new file mode 100644 index 0000000..52f31d8 --- /dev/null +++ b/test/OneDriveDirectoryTest.cpp @@ -0,0 +1,267 @@ +#include "onedrive/OneDriveDirectory.hpp" +#include "CloudSync/Cloud.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::onedrive; +using json = nlohmann::json; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +SCENARIO("OneDriveDirectory", "[directory][onedrive]") { + INIT_REQUEST(); + + GIVEN("a onedrive root directory") { + const auto directory = + std::make_shared("https://graph.microsoft.com/v1.0/me/drive/root", "/", request, ""); + + AND_GIVEN("a request that returns a valid directory listing") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{{"value", + {{{"id", "ABW1234"}, + {"@microsoft.graph.downloadUrl", + "https://public.ch.files.1drv.com/" + "filedownloadlink"}, + {"eTag", "somerevision"}, + {"name", "somefile.txt"}, + {"parentReference", {{"path", "/drive/root:"}}}, + {"file", {{"mimeType", "text/plain"}}}}, + {{"name", "somefolder"}, + {"parentReference", {{"path", "/drive/root:"}}}, + {"folder", {{"childCount", 0}}}}}}} + .dump(), + "application/json")); + + WHEN("calling ls") { + const auto dirList = directory->ls(); + + THEN("the /children graph endpoint should have been called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://graph.microsoft.com/v1.0/me/drive/root/children"); + } + + THEN("a list with one file & one directory should be returned") { + REQUIRE(dirList.size() == 2); + REQUIRE(dirList[0]->name == "somefile.txt"); + REQUIRE(dirList[0]->path == "/somefile.txt"); + REQUIRE(dirList[1]->name == "somefolder"); + REQUIRE(dirList[1]->path == "/somefolder"); + } + } + } + AND_GIVEN("a request that returns a valid folder description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"name", "somefolder"}, + {"parentReference", {{"path", "/drive/root:"}}}, + {"folder", {{"childCount", 0}}}} + .dump(), + "application/json")); + + WHEN("calling cd(somefolder), cd(/somefolder), cd(/somefolder/), cd(/somefolder/some/..)") { + std::string path = + GENERATE(as{}, "somefolder", "/somefolder", "/somefolder/", "/somefolder/some/.."); + const auto newDirectory = directory->cd(path); + THEN("the graph resource endpoint should have been called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://graph.microsoft.com/v1.0/me/drive/root:/somefolder"); + } + THEN("the folder 'somefolder' should be returned") { + REQUIRE(newDirectory->name == "somefolder"); + REQUIRE(newDirectory->path == "/somefolder"); + } + } + } + } + GIVEN("a onedrive directory (non root)") { + const auto directory = std::make_shared( + "https://graph.microsoft.com/v1.0/me/drive/root", + "/some/folder", + request, + "folder"); + AND_GIVEN("a request that returns a directory listing") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{{"value", + {{{"id", "ABW1234"}, + {"@microsoft.graph.downloadUrl", + "https://public.ch.files.1drv.com/" + "filedownloadlink"}, + {"eTag", "somerevision"}, + {"name", "somefile.txt"}, + {"parentReference", {{"path", "/drive/root:/some/folder"}}}, + {"file", {{"mimeType", "text/plain"}}}}, + {{"name", "somefolder"}, + {"parentReference", {{"path", "/drive/root:/some/folder"}}}, + {"folder", {{"childCount", 0}}}}}}} + .dump(), + "application/json")); + + WHEN("calling ls()") { + const auto dirList = directory->ls(); + + THEN("the /childern grap endpoint should be called on the resource") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://graph.microsoft.com/v1.0/me/drive/root:/some/folder:/children"); + } + + THEN("a list with one file & one directory should be returned") { + REQUIRE(dirList.size() == 2); + REQUIRE(dirList[0]->name == "somefile.txt"); + REQUIRE(dirList[0]->path == "/some/folder/somefile.txt"); + REQUIRE(dirList[1]->name == "somefolder"); + REQUIRE(dirList[1]->path == "/some/folder/somefolder"); + } + } + } + + AND_GIVEN("a request that throws 404 Resource Not Found") { + WHEN_REQUEST().Throw(request::Response::NotFound( + json{{"error", {{"code", "itemNotFound"}, {"message", "The resource could not be found."}}}}.dump())); + + WHEN("calling ls()") { + THEN("a NoSuchFileOrDirectory Exception should be thrown") { + REQUIRE_THROWS_AS(directory->ls(), Resource::NoSuchFileOrDirectory); + } + } + } + + AND_GIVEN("a request that returns a valid folder description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"name", "somefolder"}, + {"parentReference", {{"path", "/drive/root:/some/folder"}}}, + {"folder", {{"childCount", 0}}}} + .dump(), + "application/json")); + + WHEN("calling cd(somefolder)") { + const auto newDirectory = directory->cd("somefolder"); + THEN("the graph resource endpoint should have been called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == "https://graph.microsoft.com/v1.0/me/drive/root:/some/folder/somefolder"); + } + THEN("the folder 'somefolder' should be returned") { + REQUIRE(newDirectory->name == "somefolder"); + REQUIRE(newDirectory->path == "/some/folder/somefolder"); + } + } + } + + AND_GIVEN("a request that returns a valid file description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"id", "ABW1234"}, + {"eTag", "somerevision"}, + {"name", "somefile.txt"}, + {"parentReference", {{"path", "/drive/root:/some/folder"}}}, + {"file", {{"mimeType", "text/plain"}}}} + .dump(), + "application/json")); + + WHEN("calling file(somefile.txt)") { + const auto file = directory->file("somefile.txt"); + + THEN("the item endpoint should be called on the file path") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST( + 0, + url == "https://graph.microsoft.com/v1.0/me/drive/root:/some/folder/somefile.txt"); + } + + THEN("the requested file would be returned") { + REQUIRE(file->name == "somefile.txt"); + REQUIRE(file->path == "/some/folder/somefile.txt"); + REQUIRE(file->revision() == "somerevision"); + } + } + } + + AND_GIVEN("a request that throws 404 Resource Not Found") { + WHEN_REQUEST().Throw(request::Response::NotFound("")); + + WHEN("calling file(somefile.txt)") { + THEN("a NoSuchFileOrDirectory Exception should be thrown") { + REQUIRE_THROWS_AS(directory->file("somefile.txt"), Resource::NoSuchFileOrDirectory); + } + } + } + + AND_GIVEN("a request that returns a valid file description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"id", "ABW1234"}, + {"eTag", "somerevision"}, + {"name", "somefile.txt"}, + {"parentReference", {{"path", "/drive/root:/some/folder"}}}, + {"file", {{"mimeType", "text/plain"}}}} + .dump(), + "application/json")); + + WHEN("calling touch(somefile.txt)") { + const auto newFile = directory->touch("somefile.txt"); + THEN("the content endpoint should be called with no file content on the desired resource path") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PUT"); + REQUIRE_REQUEST( + 0, + url == "https://graph.microsoft.com/v1.0/me/drive/root:" + "/some/folder/somefile.txt:/content"); + REQUIRE_REQUEST(0, body == ""); + } + THEN("the new file should be returned") { + REQUIRE(newFile->name == "somefile.txt"); + REQUIRE(newFile->path == "/some/folder/somefile.txt"); + REQUIRE(newFile->revision() == "somerevision"); + } + } + } + + AND_GIVEN("a request that returns a valid folder description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + json{ + {"name", "somefolder"}, + {"parentReference", {{"path", "/drive/root:/some/folder"}}}, + {"folder", {{"childCount", 0}}}} + .dump(), + "application/json")); + + WHEN("calling mkdir(somefolder)") { + const auto newFolder = directory->mkdir("somefolder"); + THEN("the children endpoint should have been called with a json payload telling the new folder name") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "POST"); + REQUIRE_REQUEST( + 0, + url == "https://graph.microsoft.com/v1.0/me/" + "drive/root:/some/folder:/children"); + REQUIRE_REQUEST(0, body == "{\"folder\":{},\"name\":\"somefolder\"}"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Content-Type") == Request::MIMETYPE_JSON); + } + + THEN("the new folder should be returned") { + REQUIRE(newFolder->name == "somefolder"); + REQUIRE(newFolder->path == "/some/folder/somefolder"); + } + } + } + } +} diff --git a/test/OneDriveFileTest.cpp b/test/OneDriveFileTest.cpp new file mode 100644 index 0000000..eb4e1e1 --- /dev/null +++ b/test/OneDriveFileTest.cpp @@ -0,0 +1,132 @@ +#include "onedrive/OneDriveFile.hpp" +#include "request/Request.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::onedrive; +using json = nlohmann::json; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +SCENARIO("OneDriveFile", "[file][onedrive]") { + INIT_REQUEST(); + GIVEN("a OneDriveFile instance") { + const auto file = std::make_shared( + "https://graph.microsoft.com/v1.0/me/drive/root", + "/folder/file.txt", + request, + "file.txt", + "file_revision"); + AND_GIVEN("a request that returns 204") { + WHEN_REQUEST().RESPOND(request::Response(204)); + + WHEN("calling rm()") { + file->rm(); + THEN("the onedrive file endpoint should be called with DELETE") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "DELETE"); + REQUIRE_REQUEST(0, url == "https://graph.microsoft.com/v1.0/me/drive/root:/folder/file.txt"); + } + } + } + AND_GIVEN("a request that returns the files content") { + WHEN_REQUEST().RESPOND(request::Response(200, "file content")); + + WHEN("calling read()") { + const auto fileContent = file->read(); + + THEN("the content get endpoint should be called with GET") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST( + 0, + url == "https://graph.microsoft.com/v1.0/me/" + "drive/root:/folder/file.txt:/content"); + } + + THEN("the file content should be returned") { + REQUIRE(fileContent == "file content"); + } + } + } + + AND_GIVEN("a PUT request that returns a new file item description (with new revision)") { + WHEN_REQUEST().RESPOND( + request::Response(200, json{{"eTag", "new_file_revision"}}.dump(), "application/json")); + + WHEN("calling write(new file content)") { + file->write("new file content"); + THEN("the resource endpoint should be called with PUT & If-Match Header") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PUT"); + REQUIRE_REQUEST( + 0, + url == "https://graph.microsoft.com/v1.0/me/" + "drive/root:/folder/file.txt:/content"); + REQUIRE_REQUEST(0, body == "new file content"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("If-Match") == "file_revision"); + } + + THEN("the file revision should have been updated") { + REQUIRE(file->revision() == "new_file_revision"); + } + } + } + + AND_GIVEN("a request that throws a 412 Precondition Failed") { + WHEN_REQUEST().Throw(request::Response::PreconditionFailed( + json{{"error", {{"code", "notAllowed"}, {"message", "ETag does not match current item's value"}}}} + .dump())); + + WHEN("calling write(new content)") { + THEN("a ResourceHasChanged Exception should be thrown") { + REQUIRE_THROWS_AS(file->write("new content"), Resource::ResourceHasChanged); + } + } + } + + AND_GIVEN("a GET request that returns a file description (with new revision)") { + WHEN_REQUEST().RESPOND( + request::Response(200, json{{"eTag", "new_file_revision"}}.dump(), "application/json")); + + WHEN("calling pollChange()") { + bool fileChanged = file->pollChange(); + THEN("the file item endpoint should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST( + 0, + url == "https://graph.microsoft.com/v1.0/me/" + "drive/root:/folder/file.txt"); + } + THEN("the result should be true (the file has changed)") { + REQUIRE(fileChanged == true); + } + } + } + AND_GIVEN("a GET request that returns a file description (with the same old revision)") { + WHEN_REQUEST().RESPOND(request::Response(200, json{{"eTag", "file_revision"}}.dump(), "application/json")); + + WHEN("calling pollChange()") { + bool fileChanged = file->pollChange(); + THEN("the file item endpoint should be called") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST( + 0, + url == "https://graph.microsoft.com/v1.0/me/" + "drive/root:/folder/file.txt"); + } + THEN("the resutl should be false (the file has not changed)") { + REQUIRE(fileChanged == false); + } + } + } + } +} diff --git a/test/UsernamePasswordCredentialsTest.cpp b/test/UsernamePasswordCredentialsTest.cpp new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/UsernamePasswordCredentialsTest.cpp @@ -0,0 +1 @@ + diff --git a/test/WebdavCloudTest.cpp b/test/WebdavCloudTest.cpp new file mode 100644 index 0000000..1e0d2c7 --- /dev/null +++ b/test/WebdavCloudTest.cpp @@ -0,0 +1,64 @@ +#include "webdav/WebdavCloud.hpp" +#include "CloudSync/Credentials.hpp" +#include "CloudSync/Proxy.hpp" +#include "macros/access_protected.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; + +SCENARIO("WebdavCloud", "[cloud][webdav]") { + INIT_REQUEST(); + GIVEN("a webdav cloud instance") { + const auto cloud = std::make_shared("http://cloud", request); + WHEN("calling getBaseUrl") { + const auto baseUrl = cloud->getBaseUrl(); + THEN("it should return the correct base url") { + REQUIRE(baseUrl == "http://cloud"); + } + } + WHEN("calling getTokenUrl") { + const std::string result = cloud->getTokenUrl(); + THEN("an empty string should be returned") { + REQUIRE(result.empty()); + } + } + WHEN("calling getAuthorizeUrl") { + const std::string result = cloud->getAuthorizeUrl(); + THEN("an empty string should be returned") { + REQUIRE(result.empty()); + } + } + WHEN("setting the proxy") { + When(Method((*requestMock), setProxy)).Return(); + ACCESS_PROTECTED((CloudSync::Proxy), apply); + auto proxyMock = Mock(); + When(Method(proxyMock, apply)).Return(); + const auto returnedProxy = cloud->proxy(proxyMock.get()); + THEN("apply should be called on the proxyMock") { + Verify(Method(proxyMock, apply)).Once(); + } + } + WHEN("setting the credentials") { + When(Method((*requestMock), setTokenRequestUrl)).Return(); + ACCESS_PROTECTED((CloudSync::Credentials), apply); + auto credentialsMock = Mock(); + When(Method(credentialsMock, apply)).Return(); + const auto returnedCredentials = cloud->login(credentialsMock.get()); + THEN("apply should be called on the credentialsMock") { + Verify(Method(credentialsMock, apply)).Once(); + } + } + WHEN("calling root()") { + const auto directory = cloud->root(); + THEN("the root directory is returned") { + REQUIRE(directory->name == ""); + REQUIRE(directory->path == "/"); + } + } + } +} diff --git a/test/WebdavDirectoryTest.cpp b/test/WebdavDirectoryTest.cpp new file mode 100644 index 0000000..c3a8531 --- /dev/null +++ b/test/WebdavDirectoryTest.cpp @@ -0,0 +1,460 @@ +#include "webdav/WebdavDirectory.hpp" +#include "CloudSync/Cloud.hpp" +#include "CloudSync/Exceptions.hpp" +#include "request/Request.hpp" +#include "macros/access_protected.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::webdav; +using namespace CloudSync::request; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +SCENARIO("WebdavDirectory", "[directory][webdav]") { + const std::string BASE_URL = "http://cloud"; + std::string xmlQuery = "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + ""; + INIT_REQUEST(); + + GIVEN("a webdav root directory") { + + const auto directory = std::make_shared(BASE_URL, "", "/", request, ""); + + THEN("the a directory with path '/' & name '' should be returned") { + REQUIRE(directory->path == "/"); + REQUIRE(directory->name == ""); + } + + AND_GIVEN("a PROPFIND request that returns a valid webdav directory description (Depth:0)") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + "\n" + "\n" + // directory description + " \n" + " /folder/\n" + // properties for the resource + " \n" + " \n" + " Fri, 10 Jan 2020 20:42:38 GMT\n" + " "5e18e1bede073"\n" + // this is a folder + " \n" + " \n" + " HTTP/1.1 200 OK\n" + " \n" + // properties that have not been found for the resource + " \n" + " \n" + " HTTP/1.1 404 Not Found\n" + " \n" + " \n" + "", + "application/xml")); + + WHEN("calling cd(folder), cd(folder/), cd(/folder/), cd(/folder/test/..)") { + std::string path = GENERATE(as{}, "folder", "folder/", "/folder/", "/folder/test/.."); + const auto newDirectory = directory->cd(path); + THEN("a PROPFIND request on the desired folder should be made") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PROPFIND"); + REQUIRE_REQUEST(0, url == BASE_URL + "/folder"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Depth") == "0"); + REQUIRE_REQUEST(0, body == xmlQuery); + } + THEN("a object representing the new directory should be returned") { + REQUIRE(newDirectory->name == "folder"); + REQUIRE(newDirectory->path == "/folder"); + } + } + } + AND_GIVEN("a request that returns 201 and a requset that returns a PROPFIND result") { + WHEN_REQUEST() + .RESPOND(request::Response(201)) + .RESPOND(request::Response( + 200, + "\n" + "\n" + // directory description + " \n" + " /newDirectory/\n" + // properties for the resource + " \n" + " \n" + " Fri, 10 Jan 2020 20:42:38 GMT\n" + " "5e18e1bede073"\n" + " \n" + " \n" + " HTTP/1.1 200 OK\n" + " \n" + " \n" + " \n" + " HTTP/1.1 404 Not Found\n" + " \n" + " \n" + "", + "application/xml")); + + WHEN("calling mkdir(newDirectory), mkdir(newDirectory/)") { + std::string path = GENERATE(as{}, "newDirectory", "newDirectory/"); + const auto newDirectory = directory->mkdir(path); + THEN("a MKCOL request should be made on the path of the new folder") { + REQUIRE_REQUEST_CALLED().Twice(); + REQUIRE_REQUEST(0, verb == "MKCOL"); + REQUIRE_REQUEST(0, url == BASE_URL + "/newDirectory"); + } + THEN("a PROPFIND request on the new folder should be made") { + REQUIRE_REQUEST_CALLED().Twice(); + REQUIRE_REQUEST(1, verb == "PROPFIND"); + REQUIRE_REQUEST(1, url == BASE_URL + "/newDirectory"); + REQUIRE_REQUEST(1, parameters.at(P::HEADERS).at("Depth") == "0"); + REQUIRE_REQUEST(1, body == xmlQuery); + } + THEN("a object representing the new directory should be " + "returned") { + REQUIRE(newDirectory->name == "newDirectory"); + REQUIRE(newDirectory->path == "/newDirectory"); + } + } + } + AND_GIVEN("a DELETE request that returns 204") { + WHEN_REQUEST().RESPOND(request::Response(204)); + + WHEN("deleting the directory") { + directory->rmdir(); + THEN("a DELETE request should be made on the current folder") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "DELETE"); + REQUIRE_REQUEST(0, url == BASE_URL + "/"); + } + } + } + AND_GIVEN("a request that returns 201 and then a request that returns a description of the a new file") { + WHEN_REQUEST() + .RESPOND(request::Response(201)) + .RESPOND(request::Response( + 200, + "\n" + "\n" + // file + " \n" + " /newfile.txt\n" + // properties for the resource + " \n" + " \n" + " Fri, 10 Jan 2020 20:42:38 GMT\n" + " "5e18e1bede073"\n" + // this is a folder + " text/plain\n" + " \n" + " \n" + " HTTP/1.1 200 OK\n" + " \n" + " \n" + "", + "application/xml")); + + WHEN("calling touch(newfile.txt)") { + const auto newFile = directory->touch("newfile.txt"); + THEN("a PUT request should be made on path of the file to be created") { + REQUIRE_REQUEST_CALLED().Twice(); + REQUIRE_REQUEST(0, verb == "PUT"); + REQUIRE_REQUEST(0, url == BASE_URL + "/newfile.txt"); + REQUIRE_REQUEST(0, body == ""); + } + THEN("a PROPFIND request should be made on the new file") { + REQUIRE_REQUEST_CALLED().Twice(); + REQUIRE_REQUEST(1, verb == "PROPFIND"); + REQUIRE_REQUEST(1, url == BASE_URL + "/newfile.txt"); + REQUIRE_REQUEST(1, parameters.at(P::HEADERS).at("Depth") == "0"); + REQUIRE_REQUEST(1, body == xmlQuery); + } + THEN("an object representing the file should be returned") { + REQUIRE(newFile->name == "newfile.txt"); + REQUIRE(newFile->path == "/newfile.txt"); + REQUIRE(newFile->revision() == "\"5e18e1bede073\""); + } + } + } + + AND_GIVEN("a request that returns a valid root webdav directory listing") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + "" + "" + // root directory + " " + " /" + // properties for the resource + " " + " " + " Fri, 10 Jan 2020 20:42:38 GMT" + " "5e18e1bede073"" + // this is a folder + " " + " " + " HTTP/1.1 200 OK" + " " + // properties that have not been found for the resource + " " + " " + " HTTP/1.1 404 Not Found" + " " + " " + + // subfolder + " " + " /subfolder/" + // properties for the resource + " " + " " + " Fri, 10 Jan 2020 20:42:38 GMT" + " "5e18e1bede073"" + // this is a folder + " " + " " + " HTTP/1.1 200 OK" + " " + // properties that have not been found for the resource + " " + " " + " HTTP/1.1 404 Not Found" + " " + " " + + // file + " " + " /somefile.txt" + // properties for the resource + " " + " " + " Fri, 10 Jan 2020 20:42:38 GMT" + " "5e18e1bede073"" + // this is a folder + " text/plain" + " " + " " + " HTTP/1.1 200 OK" + " " + " " + "", + "application/xml")); + + WHEN("calling ls()") { + const auto dirlist = directory->ls(); + THEN("a PROPFIND request should be made on the current directory") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PROPFIND"); + REQUIRE_REQUEST(0, url == BASE_URL + "/"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Depth") == "1"); + REQUIRE_REQUEST(0, body == xmlQuery); + } + THEN("a list of 2 resources should be returned") { + REQUIRE(dirlist.size() == 2); + } + THEN("the first item should be a directory named 'subfolder/'") { + REQUIRE(dirlist[0]->name == "subfolder"); + REQUIRE(dirlist[0]->path == "/subfolder"); + } + THEN("the second item should be a textfile named 'somefile.txt'") { + const auto file = std::dynamic_pointer_cast(dirlist[1]); + REQUIRE(file->name == "somefile.txt"); + REQUIRE(file->path == "/somefile.txt"); + REQUIRE(file->revision() == "\"5e18e1bede073\""); + } + } + } + AND_GIVEN("a PROPFIND request that returns a valid file description") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + "" + "" + // file + " " + " /some/path/somefile.txt" + " " + " " + " Fri, 10 Jan 2020 20:42:38 GMT" + " "5e18e1bede073"" + " text/plain" + " " + " " + " HTTP/1.1 200 OK" + " " + " " + "", + "application/xml")); + + WHEN("calling file(/some/path/somefile.txt)") { + const auto file = directory->file("some/path/somefile.txt"); + THEN("a PROPFIND request should be made to the requested file") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PROPFIND"); + REQUIRE_REQUEST(0, url == BASE_URL + "/some/path/somefile.txt"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Depth") == "0"); + REQUIRE_REQUEST(0, body == xmlQuery); + } + THEN("the file should be returned") { + REQUIRE(file->name == "somefile.txt"); + REQUIRE(file->path == "/some/path/somefile.txt"); + REQUIRE(file->revision() == "\"5e18e1bede073\""); + } + } + } + AND_GIVEN("a PROPFIND request that returns a xml that misses the expected content") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + "" + "", + "application/xml")); + + WHEN("calling ls()") { + THEN("a RequestException should be thrown") { + REQUIRE_THROWS_AS(directory->ls(), CloudSync::Cloud::InvalidResponse); + } + } + WHEN("calling cd(test)") { + THEN("a RequestException should be thrown") { + REQUIRE_THROWS_AS(directory->cd("test"), CloudSync::Cloud::InvalidResponse); + } + } + } + AND_GIVEN("a PROPFIND request that returns no xml") { + WHEN_REQUEST().RESPOND(request::Response(200, "noxml", "text/plain")); + + WHEN("getting the current dir content") { + THEN("a RequestException should be thrown") { + REQUIRE_THROWS_AS(directory->ls(), CloudSync::Cloud::InvalidResponse); + } + } + WHEN("calling cd(test)") { + THEN("a RequestException should be thrown") { + REQUIRE_THROWS_AS(directory->cd("test"), CloudSync::Cloud::InvalidResponse); + } + } + } + } + + GIVEN("a webdav directory (non-root)") { + const auto directory = std::make_shared(BASE_URL, "", "/some/folder", request, "folder"); + + THEN("the a directory with path '/some/folder' & name 'folder' should " + "be returned") { + REQUIRE(directory->path == "/some/folder"); + REQUIRE(directory->name == "folder"); + } + + AND_GIVEN("a PROPFIND request that returns a valid webdav directory description (Depth:0)") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + "" + "" + // directory description + " " + " /some/folder/somefolder/" + // properties for the resource + " " + " " + " Fri, 10 Jan 2020 20:42:38 GMT" + " "5e18e1bede073"" + " " + " " + " HTTP/1.1 200 OK" + " " + // properties that have not been found for the resource + " " + " " + " HTTP/1.1 404 Not Found" + " " + " " + "", + "application/xml")); + + WHEN("calling cd(somefolder), cd(somefolder/), cd(somefolder/morefolder/..), cd(/somefolder/)") { + std::string path = GENERATE( + as{}, + "somefolder", + "somefolder/", + "somefolder/morefolder/..", + "/somefolder/"); + const auto newDirectory = directory->cd(path); + THEN("a PROPFIND request on the desired folder should be made") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PROPFIND"); + REQUIRE_REQUEST(0, url == BASE_URL + "/some/folder/somefolder"); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Depth") == "0"); + REQUIRE_REQUEST(0, body == xmlQuery); + } + THEN("a object representing the new directory should be " + "returned") { + REQUIRE(newDirectory->name == "somefolder"); + REQUIRE(newDirectory->path == "/some/folder/somefolder"); + } + } + } + } + + GIVEN("a webdav directory with a nextcloud/owncloud dirOffset") { + const auto nextcloudDir = + std::make_shared(BASE_URL, "/remote.php/webdav", "/some/folder", request, "folder"); + + AND_GIVEN("a PROPFIND request that returns a valid webdav directory description (Depth:0) including the " + "dirOffset of nextcloud") { + WHEN_REQUEST().RESPOND(request::Response( + 200, + "" + "" + // directory description + " " + " /remote.php/webdav/some/folder/somefolder/" + // properties for the resource + " " + " " + " Fri, 10 Jan 2020 20:42:38 GMT" + " "5e18e1bede073"" + // this is a folder + " " + " " + " HTTP/1.1 200 OK" + " " + // properties that have not been found for the resource + " " + " " + " HTTP/1.1 404 Not Found" + " " + " " + "", + "application/xml")); + + WHEN("calling cd(somefolder)") { + const auto newNextcloudDir = nextcloudDir->cd("somefolder"); + THEN("a PROPFIND request should be made on the correct resource Path") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PROPFIND"); + REQUIRE_REQUEST(0, url == BASE_URL + "/remote.php/webdav/some/folder/somefolder"); + REQUIRE_REQUEST(0, body == xmlQuery); + } + + THEN("the folder 'somefolder' should be returned with the path '/some/folder/somefolder'") { + REQUIRE(newNextcloudDir->name == "somefolder"); + REQUIRE(newNextcloudDir->path == "/some/folder/somefolder"); + } + } + } + } +} diff --git a/test/WebdavFileTest.cpp b/test/WebdavFileTest.cpp new file mode 100644 index 0000000..3db739b --- /dev/null +++ b/test/WebdavFileTest.cpp @@ -0,0 +1,149 @@ +#include "webdav/WebdavFile.hpp" +#include "CloudSync/Cloud.hpp" +#include "CloudSync/Exceptions.hpp" +#include "request/Request.hpp" +#include "macros/access_protected.hpp" +#include "macros/request_mock.hpp" +#include "macros/shared_ptr_mock.hpp" +#include +#include +#include + +using namespace fakeit; +using namespace Catch; +using namespace CloudSync; +using namespace CloudSync::webdav; +using namespace CloudSync::request; +using P = request::Request::ParameterType; + +std::string xmlResponseContent(const std::string &eTag) { + return "" + "" + " " + " /file.txt" + " " + " " + " "" + + eTag + + """ + " " + " HTTP/1.1 200 OK" + " " + " " + ""; +} + +SCENARIO("WebdavFile", "[file][webdav]") { + const std::string BASE_URL = "http://cloud"; + INIT_REQUEST(); + + GIVEN("a webdav file instance of a textfile") { + const auto file = std::make_shared( + BASE_URL, + "/test.txt", + request, + "test.txt", + "\"7f3805660b049baadd3bef287d7d346b\""); + AND_GIVEN("a request that returns 204 and a new eTag") { + WHEN_REQUEST().RESPOND(request::Response(204, "", "text/plain", {{"etag", "\"newRevision\""}})); + + WHEN("writing to the file") { + const std::string newData = "awesome new data"; + file->write(newData); + THEN("a PUT request should be made on the path pointing to the file") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PUT"); + REQUIRE_REQUEST(0, url == BASE_URL + "/test.txt"); + REQUIRE_REQUEST(0, body == newData); + REQUIRE_REQUEST( + 0, + parameters.at(P::HEADERS).at("If-Match") == "\"7f3805660b049baadd3bef287d7d346b\""); + } + THEN("the file should have a new Revision") { + REQUIRE(file->revision() == "\"newRevision\""); + } + } + } + AND_GIVEN("a request that returns 200") { + WHEN_REQUEST().RESPOND(request::Response(200, "testtext", "text/plain")); + + WHEN("reading from a file") { + std::string data = file->read(); + THEN("a GET request should be made on the desired file") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "GET"); + REQUIRE_REQUEST(0, url == BASE_URL + "/test.txt"); + } + } + } + AND_GIVEN("a request that returns 204") { + WHEN_REQUEST().RESPOND(request::Response(204)); + WHEN("deleting the file") { + file->rm(); + THEN("a DELETE request should be done on the resource") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, url == BASE_URL + "/test.txt"); + } + } + } + + AND_GIVEN("a PROPFIND request that returns the same etag (no change)") { + WHEN_REQUEST().RESPOND( + request::Response(200, xmlResponseContent("7f3805660b049baadd3bef287d7d346b"), "application/xml")); + + WHEN("calling pollChange()") { + const bool hasChanged = file->pollChange(); + THEN("a PROPFIND request should be made to the resource asking for the etag") { + REQUIRE_REQUEST_CALLED().Once(); + REQUIRE_REQUEST(0, verb == "PROPFIND"); + REQUIRE_REQUEST(0, url == BASE_URL + "/test.txt"); + REQUIRE_REQUEST( + 0, + body == "\n" + "\n" + " \n" + " \n" + " \n" + ""); + REQUIRE_REQUEST(0, parameters.at(P::HEADERS).at("Depth") == "0"); + } + THEN("false should be returned") { + REQUIRE(hasChanged == false); + } + } + } + AND_GIVEN("a request that returns a new etag (change)") { + WHEN_REQUEST().RESPOND( + request::Response(200, xmlResponseContent("1ab803660mm49baads3bef287d7d3466"), "application/xml")); + + WHEN("calling pollChange()") { + const bool hasChanged = file->pollChange(); + THEN("true should be returned") { + REQUIRE(hasChanged); + } + THEN("the file should have a new revision") { + REQUIRE(file->revision() == "\"1ab803660mm49baads3bef287d7d3466\""); + } + } + } + AND_GIVEN("a request that returns an invalid xml (not paresable)") { + WHEN_REQUEST().RESPOND(request::Response(200, "thisisnotxml", "text/plain")); + + WHEN("calling pollChange()") { + THEN("an InvalidResponse Exception should be thrown") { + REQUIRE_THROWS_AS(file->pollChange(), CloudSync::Cloud::InvalidResponse); + } + } + } + AND_GIVEN("a request that returns valid xml that misses the etag field") { + WHEN_REQUEST().RESPOND(request::Response(200, "a", "text/plain")); + + WHEN("calling pollChange()") { + THEN("an InvalidResponse Exception should be thrown") { + REQUIRE_THROWS_AS(file->pollChange(), CloudSync::Cloud::InvalidResponse); + } + } + } + } +} diff --git a/test/macros/access_protected.hpp b/test/macros/access_protected.hpp new file mode 100644 index 0000000..c8b7aab --- /dev/null +++ b/test/macros/access_protected.hpp @@ -0,0 +1,10 @@ +#pragma once + +#define ACCESS_PROTECTED(A, M) \ + struct M##_struct : get_a1::type { \ + using get_a1::type::M; \ + } +#define CALL_PROTECTED(B, M) ((B).*&M##_struct::M) + +template struct get_a1; +template struct get_a1 { typedef A1 type; }; diff --git a/test/macros/request_mock.hpp b/test/macros/request_mock.hpp new file mode 100644 index 0000000..2b26216 --- /dev/null +++ b/test/macros/request_mock.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "request/Request.hpp" +#include "shared_ptr_mock.hpp" +#include +#include +#include + +struct RequestRecording { + RequestRecording( + const std::string &verb, const std::string &url, + const std::unordered_map< + CloudSync::request::Request::ParameterType, const std::unordered_map> ¶meters, + const std::string &body) + : verb(verb), url(url), parameters(parameters), body(body){}; + const std::string verb; + const std::string url; + const std::unordered_map< + CloudSync::request::Request::ParameterType, const std::unordered_map> + parameters; + const std::string body; +}; + +static std::vector requestRecording; + +#define INIT_REQUEST() \ + requestRecording.clear(); \ + SHARED_PTR_MOCK(request, request::Request); + +#define WHEN_REQUEST() When(Method((*requestMock), request)) + +#define RESPOND(returnvalue) \ + Do([](const std::string &verb, \ + const std::string &url, \ + const std::unordered_map< \ + CloudSync::request::Request::ParameterType, \ + const std::unordered_map> \ + parameters, \ + const std::string &body) { \ + requestRecording.push_back(RequestRecording(verb, url, parameters, body)); \ + return returnvalue; \ + }) + +#define REQUIRE_REQUEST(number, condition) REQUIRE(requestRecording.at(number).condition) + +#define REQUIRE_REQUEST_CALLED() Verify(Method((*requestMock), request)) diff --git a/test/macros/shared_ptr_mock.hpp b/test/macros/shared_ptr_mock.hpp new file mode 100644 index 0000000..272257b --- /dev/null +++ b/test/macros/shared_ptr_mock.hpp @@ -0,0 +1,6 @@ +#pragma once + +#define SHARED_PTR_MOCK(name, type) \ + const auto name##Mock = new Mock(); \ + Fake(Dtor((*name##Mock))); \ + const auto name = std::shared_ptr(&name##Mock->get()); diff --git a/test/main.cpp b/test/main.cpp new file mode 100644 index 0000000..7e28fd6 --- /dev/null +++ b/test/main.cpp @@ -0,0 +1,7 @@ +#define CATCH_CONFIG_RUNNER // This tells Catch to provide a main() - only do + // this in one cpp file +#include + +int main(int argc, char *argv[]) { + return Catch::Session().run(argc, argv); +} diff --git a/test/mylibrarytest.cpp b/test/mylibrarytest.cpp deleted file mode 100644 index e4e1389..0000000 --- a/test/mylibrarytest.cpp +++ /dev/null @@ -1,19 +0,0 @@ -#define CATCH_CONFIG_MAIN -#include -#include -#include "MyLibrary/example.hpp" - -using namespace MyLibrary; - -SCENARIO("MyLibrary testing", "[test]") { - GIVEN("an Example instance") { - auto example = std::make_shared(); - - WHEN("calling test()") { - auto result = example->test("test"); - THEN("result should contain a test json") { - REQUIRE(!result.empty()); - } - } - } -} \ No newline at end of file diff --git a/test_package/CMakeLists.txt b/test_package/CMakeLists.txt index ce35837..d9ce6d6 100644 --- a/test_package/CMakeLists.txt +++ b/test_package/CMakeLists.txt @@ -2,14 +2,14 @@ cmake_minimum_required(VERSION 3.15) include(${CMAKE_BINARY_DIR}/conan_paths.cmake OPTIONAL) project(PackageTest LANGUAGES CXX) -find_package(MyLibrary REQUIRED) -add_executable(example example.cpp) -target_link_libraries(example MyLibrary::MyLibrary) -target_compile_features(example PRIVATE cxx_std_17) -set_target_properties(example +find_package(CloudSync) +add_executable(PackageTest example.cpp) +target_link_libraries(PackageTest CloudSync::CloudSync) +target_compile_features(PackageTest PRIVATE cxx_std_17) +set_target_properties(PackageTest PROPERTIES RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/bin" RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}/bin" - RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFOE "${CMAKE_BINARY_DIR}/bin" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}/bin" ) \ No newline at end of file diff --git a/test_package/conanfile.py b/test_package/conanfile.py index 660ca1a..eae305c 100644 --- a/test_package/conanfile.py +++ b/test_package/conanfile.py @@ -1,7 +1,7 @@ from conans import ConanFile, CMake, tools import os -class MyLibraryTestConan(ConanFile): +class CloudSyncTestConan(ConanFile): settings = "os", "compiler", "build_type", "arch" generators = "cmake_find_package", "cmake_paths" @@ -17,4 +17,4 @@ def imports(self): def test(self): if not tools.cross_building(self): os.chdir("bin") - self.run(".%sexample" % os.sep) \ No newline at end of file + self.run(".%sPackageTest" % os.sep) \ No newline at end of file diff --git a/test_package/example.cpp b/test_package/example.cpp index bdc37fb..feb419e 100644 --- a/test_package/example.cpp +++ b/test_package/example.cpp @@ -1,9 +1,9 @@ -#include "MyLibrary/example.hpp" +#include "CloudSync/CloudFactory.hpp" #include -using namespace MyLibrary; +using namespace CloudSync; int main() { - auto example = std::make_shared(); - example->test("test"); + auto factory = std::make_shared(); + factory->dropbox(); }