diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e0203c7..08e2160 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,29 +1,50 @@ name: Tests + on: push: - workflow_dispatch: - schedule: - - cron: "0 0 * * 0" + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 */6 * * *" + jobs: run_tests: name: Run Tests runs-on: ubuntu-latest + env: + DB_CONN_STR: ${{ vars.DB_CONN_STR }} + DB_USERNAME: ${{ vars.DB_USERNAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + strategy: + matrix: + java-version: ["17", "21"] steps: - - uses: actions/checkout@v2 - - name: Set up JDK 17 - uses: actions/setup-java@v2 + - name: Update repositories + run: | + sudo apt update || echo "apt-update failed" # && apt -y upgrade + + - name: Checkout ${{ github.event.repository.name }} + uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java-version }} + uses: actions/setup-java@v4 with: - java-version: 17 - distribution: 'adopt' - cache: gradle - - id: run + java-version: ${{ matrix.java-version }} + distribution: "adopt" + cache: "gradle" + + - name: Run Gradle Tests + id: run run: | - ./gradlew --no-daemon test + chmod +x gradlew + ./gradlew clean test --info --stacktrace + - name: Report Status if: always() uses: ravsamhq/notify-slack-action@v1 with: status: ${{ job.status }} - notify_when: 'failure' + notify_when: "failure" env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.gitignore b/.gitignore index a1fc39c..ff5af65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,17 @@ +*.iml + .gradle -/build/ +build/ +target/ +out/ +.vscode/ + +# Default ignored files +shelf/ +.idea/ +/workspace.xml +.idea_modules/ +sonarlint/ # Ignore Gradle GUI config gradle-app.setting diff --git a/.gitpod.Dockerfile b/.gitpod.Dockerfile deleted file mode 100644 index 98b0e51..0000000 --- a/.gitpod.Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM public.ecr.aws/z2f7n8a1/couchbase-da-containers:couchbase-neo - -RUN echo "* soft nproc 20000\n"\ -"* hard nproc 20000\n"\ -"* soft nofile 200000\n"\ -"* hard nofile 200000\n" >> /etc/security/limits.conf - -#Simple example on how to extend the image to install Java and maven -RUN apt-get -qq update && \ - apt-get install -yq maven default-jdk sudo git - -RUN chmod -R g+rwX /opt/couchbase && \ - addgroup --gid 33333 gitpod && \ - useradd --no-log-init --create-home --home-dir /home/gitpod --shell /bin/bash --uid 33333 --gid 33333 gitpod && \ - usermod -a -G gitpod,couchbase,sudo gitpod && \ - echo 'gitpod ALL=(ALL) NOPASSWD:ALL'>> /etc/sudoers - -COPY startcb.sh /opt/couchbase/bin/startcb.sh -USER gitpod diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index a439b9c..0000000 --- a/.gitpod.yml +++ /dev/null @@ -1,33 +0,0 @@ -image: - file: .gitpod.Dockerfile - -tasks: -- name: Start Couchbase - command: ./startcb.sh -- name: Log use - command: curl -s 'https://da-demo-images.s3.amazonaws.com/runItNow_outline.png?couchbase-example=java-springdata-quickstart-repo&source=gitpod' > /dev/null -- name: Start app - command: ./gradlew bootRun -# exposed ports -ports: -- port: 8080 - onOpen: open-preview -- port: 8091 - onOpen: open-browser -- port: 8092-10000 - onOpen: ignore -- port: 4369 - onOpen: ignore - -github: - prebuilds: - # enable for the master/default branch (defaults to true) - master: true - -vscode: - extensions: - - redhat.java - - vscjava - - vscjava.vscode-java-debug - - vscjava.vscode-java-test - - pivotal.vscode-spring-boot diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a827f93 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Get latest java +FROM eclipse-temurin:17-jdk-jammy AS build + +# Set the working directory +WORKDIR /app + +# Copy the build.gradle and settings.gradle files +COPY build.gradle . +COPY settings.gradle . +COPY gradlew . +COPY gradle ./gradle + +# Copy the src directory +COPY src ./src + +# Build the application without running the tests and with stacktrace +RUN ./gradlew clean build -x test --stacktrace + +# Expose port 8080 +EXPOSE 8080 + +# Run the application +ENTRYPOINT ["java","-jar","/app/build/libs/java-springdata-quickstart-0.0.1-SNAPSHOT.jar"] + +# Build the image +# docker build -t java-springdata-quickstart . + +# Run the container +# docker run -d --name springdata-container -p 9440:8080 java-springdata-quickstart -e DB_CONN_STR= -e DB_USERNAME= -e DB_PASSWORD= \ No newline at end of file diff --git a/README.md b/README.md index e752716..1a922e7 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,256 @@ -[![Try it now!](https://da-demo-images.s3.amazonaws.com/runItNow_outline.png?couchbase-example=java-springdata-quickstart-repo&source=github)](https://gitpod.io/#https://github.com/couchbase-examples/java-springdata-quickstart) +# Quickstart in Couchbase with Spring Data and Java -## Overview -This quickstart tutorial will review the basics of using Couchbase by building a simple Spring Data REST API that stores user profiles is used as an example. +#### REST API using Couchbase Capella in Java using Spring Data -## What We'll Cover -- [Cluster Connection Configuration](#cluster-connection-configuration) – Configuring Spring Data to connect to a Couchbase cluster. -- [Database Initialization](#database-initialization) – Creating required database structures upon application startup -- [CRUD operations](#create-or-update-a-profile) – Standard create, update and delete operations. -- [Custom SQL++ queries](#search-profiles-by-text) – Using [SQl++](https://www.couchbase.com/sqlplusplus) with Spring Data. +Often, the first step developers take after creating their database is to create a REST API that can perform Create, Read, Update, and Delete (CRUD) operations for that database. This repo is designed to teach you and give you a starter project (in Java using Spring Data) to generate such a REST API. After you have installed the travel-sample bucket in your database, you can run this application which is a REST API with Swagger documentation so that you can learn: -## Useful Links -- [Spring Data Couchbase - Reference Documentation](https://docs.spring.io/spring-data/couchbase/docs/current/reference/html/) -- [Spring Data Couchbase - JavaDoc](https://docs.spring.io/spring-data/couchbase/docs/current/api/) +1. How to create, read, update, and delete documents using Key-Value[ operations](https://docs.couchbase.com/java-sdk/current/howtos/kv-operations.html) (KV operations). KV operations are unique to Couchbase and provide super fast (think microseconds) queries. +2. How to write simple parametrized [SQL++ Queries](https://docs.couchbase.com/java-sdk/current/howtos/n1ql-queries-with-sdk.html) using the built-in travel-sample bucket. + +Full documentation for the tutorial can be found on the [Couchbase Developer Portal](https://developer.couchbase.com/tutorial-quickstart-spring-data-java/). ## Prerequisites -To run this prebuild project, you will need: -- [Couchbase Capella](https://docs.couchbase.com/cloud/get-started/create-account.html) account or locally installed [Couchbase Server](/tutorial-couchbase-installation-options) -- [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -- Code Editor or an Integrated Development Environment (e.g., [Eclipse](https://www.eclipse.org/ide/)) -- [Java SDK v1.8 or higher installed](https://www.oracle.com/java/technologies/ee8-install-guide.html) -- [Gradle Build Tool](https://gradle.org/install/) - -## Source Code -The sample source code used in this tutorial is [published on GitHub](https://github.com/couchbase-examples/java-springboot-quickstart). -To obtain it, clone the git repository with your IDE or execute the following command: + +To run this prebuilt project, you will need: + +- [Couchbase Capella](https://www.couchbase.com/products/capella/) cluster with [travel-sample](https://docs.couchbase.com/java-sdk/current/ref/travel-app-data-model.html) bucket loaded. + - To run this tutorial using a self-managed Couchbase cluster, please refer to the [appendix](#running-self-managed-couchbase-cluster). +- [Java 17 or higher](https://www.oracle.com/java/technologies/javase-downloads.html) + - Ensure that the Java version is compatible with the Couchbase SDK. +- [Loading Travel Sample Bucket](https://docs.couchbase.com/cloud/clusters/data-service/import-data-documents.html#import-sample-data) + - If `travel-sample` is not loaded in your Capella cluster, you can load it by following the instructions for your Capella Cluster +- [Gradle 8.6 or higher](https://gradle.org/releases/) + +## App Setup + +We will walk through the different steps required to get the application running. + +### Cloning Repo + ```shell -git clone https://github.com/couchbase-examples/java-springdata-quickstart +git clone https://github.com/couchbase-examples/java-springboot-quickstart +``` + +### Install Dependencies + +The dependencies for the application are specified in the `build.gradle` file in the source folder. Dependencies can be installed through `gradle` the default package manager for Java. + ``` -## Dependencies -Gradle dependencies: -```groovy -implementation 'org.springframework.boot:spring-boot-starter-web' -// spring data couchbase connector -implementation 'org.springframework.boot:spring-boot-starter-data-couchbase' -// swagger ui -implementation 'org.springdoc:springdoc-openapi-ui:1.6.6' +gradle build -x test ``` -Maven dependencies: -```xml - - org.springframework.boot - spring-boot-starter-data-couchbase - - - org.springframework.boot - spring-boot-starter-web - - - org.springdoc - springdoc-openapi-ui - 1.6.6 - +Note: The `-x test` option is used to skip the tests. The tests require the application to be running. + +Note: The application is tested with Java 17. If you are using a different version of Java, please update the `build.gradle` file accordingly. + +### Setup Database Configuration + +To learn more about connecting to your Capella cluster, please follow the [instructions](https://docs.couchbase.com/cloud/get-started/connect.html). + +Specifically, you need to do the following: + +- Create the [database credentials](https://docs.couchbase.com/cloud/clusters/manage-database-users.html) to access the travel-sample bucket (Read and Write) used in the application. +- [Allow access](https://docs.couchbase.com/cloud/clusters/allow-ip-address.html) to the Cluster from the IP on which the application is running. + +All configuration for communication with the database is read from the environment variables. We have provided a convenience feature in this quickstart to read the environment variables from a local file, `application.properties` in the `src/main/resources` folder. + +You can also set the environment variables directly in your environment such as: + +```sh +export DB_CONN_STR=couchbases:// +export DB_USERNAME=Administrator +export DB_PASSWORD=password ``` +The `application.properties` file should look like this: + +```properties +server.forward-headers-strategy=framework +spring.couchbase.bootstrap-hosts=DB_CONN_STR +spring.couchbase.bucket.name=travel-sample +spring.couchbase.bucket.user=DB_USERNAME +spring.couchbase.bucket.password=DB_PASSWORD +spring.couchbase.scope.name=inventory +``` + +You can specify the connection string, username, and password using environment variables. The application will read these environment variables and use them to connect to the database. + +Additionally, you can specify the connection string, username, and password directly in the `application.properties` file. + +> Note: The connection string expects the `couchbases://` or `couchbase://` part. + ## Cluster Connection Configuration + Spring Data couchbase connector can be configured by providing a `@Configuration` [bean](https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans-definition) that extends [`AbstractCouchbaseConfiguration`](https://docs.spring.io/spring-data/couchbase/docs/current/api/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.html). -The sample application provides a configuration bean that uses default couchbase login and password: + ```java +@Slf4j @Configuration +@EnableCouchbaseRepositories public class CouchbaseConfiguration extends AbstractCouchbaseConfiguration { + @Value("#{systemEnvironment['DB_CONN_STR'] ?: '${spring.couchbase.bootstrap-hosts:localhost}'}") + private String host; + + @Value("#{systemEnvironment['DB_USERNAME'] ?: '${spring.couchbase.bucket.user:Administrator}'}") + private String username; + + @Value("#{systemEnvironment['DB_PASSWORD'] ?: '${spring.couchbase.bucket.password:password}'}") + private String password; + + @Value("${spring.couchbase.bucket.name:travel-sample}") + private String bucketName; + @Override public String getConnectionString() { - // capella - // return "couchbases://cb.jnym5s9gv4ealbe.cloud.couchbase.com"; - - // localhost - return "127.0.0.1" + return host; } @Override public String getUserName() { - return "Administrator"; + return username; } @Override public String getPassword() { - return "password"; + return password; } @Override public String getBucketName() { - return "springdata_quickstart"; + return bucketName; + } + + @Override + public String typeKey() { + return "type"; + } + + @Override + @Bean(destroyMethod = "disconnect") + public Cluster couchbaseCluster(ClusterEnvironment couchbaseClusterEnvironment) { + try { + log.debug("Connecting to Couchbase cluster at " + host); + return Cluster.connect(getConnectionString(), getUserName(), getPassword()); + } catch (Exception e) { + log.error("Error connecting to Couchbase cluster", e); + throw e; + } } - ... + @Bean + public Bucket getCouchbaseBucket(Cluster cluster) { + try { + if (!cluster.buckets().getAllBuckets().containsKey(getBucketName())) { + log.error("Bucket with name {} does not exist. Creating it now", getBucketName()); + throw new BucketNotFoundException(bucketName); + } + return cluster.bucket(getBucketName()); + } catch (Exception e) { + log.error("Error getting bucket", e); + throw e; + } + } + +} ``` -> *from config/CouchbaseConfiguration.java* + +> _from config/CouchbaseConfiguration.java_ This default configuration assumes that you have a locally running Couchbae server and uses standard administrative login and password for demonstration purpose. Applications deployed to production or staging environments should use less privileged credentials created using [Role-Based Access Control](https://docs.couchbase.com/go-sdk/current/concept-docs/rbac.html). Please refer to [Managing Connections using the Java SDK with Couchbase Server](https://docs.couchbase.com/java-sdk/current/howtos/managing-connections.html) for more information on Capella and local cluster connections. -# Running the Application +## Running The Application -To install dependencies and run the application on Linux, Unix or OS X, execute `./gradlew bootRun` (`./gradew.bat bootRun` on Windows). +### Directly on Machine -Once the site is up and running, you can launch your browser and go to the [Swagger Start Page](http://localhost:8080/swagger-ui/) to test the APIs. +At this point, we have installed the dependencies, loaded the travel-sample data and configured the application with the credentials. The application is now ready and you can run it. +```sh +gradle bootRun +``` -## Document Structure -We will be setting up a REST API to manage demo user profiles and store them as documents on a Couchbase Cluster. Every document needs a unique identifier with which it can be addressed in the API. We will use auto-generated UUIDs for this purpose and store in profile documents the first and the last name of the user, their age, and address: +Note: If you're using Windows, you can run the application using the `gradle.bat` executable. -```json -{ - "id": "b181551f-071a-4539-96a5-8a3fe8717faf", - "firstName": "John", - "lastName": "Doe", - "age": "35", - "address": "123 Main St" -} +```sh +./gradew.bat bootRun ``` -## Let's Review the Code +### Using Docker -### Profile Model -To work with submitted profiles, we first need to model their structure in a Java class, which would define the set of profile fields and their types. -In our sample application, this is done in [`model/Profile.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/trycb/model/Profile.java) class: +Build the Docker image -```java -@Scope("_default") -@Collection("profile") -public class Profile { - @id - @GeneratedValue - private UUID id; - private String firstName, lastName; - private byte age; - private String address; - - // ... -} +```sh +docker build -t java-springdata-quickstart . ``` -> from `model/Profile.java` -The whole model is annotated with `@Scope` and `@Collection` annotations, which configure Spring Data to store model instances into `profile` collection in the default scope. +Run the Docker image -It is also worth noting the use of `@Id` and `@GeneratedValue` annotations on `Profile::id` field. +```sh +docker run -d --name springdata-container -p 8080:8080 java-springdata-quickstart +``` -In couchbase, data is stored as JSON documents; each document has a unique identifier that can be used to address that document. -Every profile instance in our example corresponds to a single document and this annotation is used here to link the document's id to a java field. -Additionally, the `@GeneratedValue` annotation on the field instructs Spring Data to generate a random UUID if we try to store a profile without one, which will come in handy later. +Note: The `application.properties` file has the connection information to connect to your Capella cluster. These will be part of the environment variables in the Docker container. -You can find more information on key generation in the [Connector Documentation](https://docs.spring.io/spring-data/couchbase/docs/current/reference/html/#couchbase.autokeygeneration). +### Verifying the Application -Couchbase Spring Data connector will automatically serialize model instances into JSON when storing them on the cluster. +Once the application starts, you can see the details of the application on the logs. -### Database initialization -Automated database initialization and migration is a common solution that simplifies database management operations. -To keep it simple, our demo uses [DbSetupRunner](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/trycb/config/DbSetupRunner.java) component class that implements `CommandLineRunner` interface and is invoked every time the application starts. -The runner tries to create required structure every startup but ignores any errors if such structure already exists. -For example, this code creates a primary index for configured bucket: -```java - try { - // We must create primary index on our bucket in order to query it - cluster.queryIndexes().createPrimaryIndex(config.getBucketName()); - LOGGER.info("Created primary index {}", config.getBucketName()); - } catch (IndexExistsException iee) { - LOGGER.info("Primary index {} already exists", config.getBucketName()); - } -``` -> From `config/DbSetupRunner.java` +![Application Startup](./assets/images/app-startup-spring-data.png) -Primary indexes in Couchbase contain all document keys and are used to fetch documents by their unique identifiers. -Secondary indexes can be used to efficiently query documents by their properties. -For example, `DbSetupRunner` creates additional indexes on the collection that allow querying profiles by first or last names or addresses: -```java - try { - final String query = "CREATE INDEX secondary_profile_index ON " + config.getBucketName() + "._default." + CouchbaseConfiguration.PROFILE_COLLECTION + "(firstName, lastName, address)"; - cluster.query(query); - } catch (IndexExistsException e) { - LOGGER.info("Secondary index exists on collection {}", CouchbaseConfiguration.PROFILE_COLLECTION); - } -``` -> From `config/DbSetupRunner.java` +The application will run on port 8080 of your local machine (http://localhost:8080). You will find the interactive Swagger documentation of the API if you go to the URL in your browser. Swagger documentation is used in this demo to showcase the different API endpoints and how they can be invoked. More details on the Swagger documentation can be found in the [appendix](#swagger-documentation). -More information on working with Couchbase indexes can be found [in our documentation](https://docs.couchbase.com/server/current/learn/services-and-indexes/indexes/global-secondary-indexes.html). +![Swagger Documentation](./assets/images/swagger-documentation-spring-data.png) -### Create or update a Profile -For CRUD operations, we will extend [`PagingAndSortingRepository`](https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/repository/PagingAndSortingRepository.html) provided by the framework: +## Running Tests -```java -@Repository -public interface ProfileRepository extends PagingAndSortingRepository { - @Query("#{#n1ql.selectEntity} WHERE firstName LIKE '%' || $1 || '%' OR lastName LIKE '%' || $1 || '%' OR address LIKE '%' || $1 || '%' OFFSET $2 * $3 LIMIT $3") - List findByText(String query, int pageNum, int pageSize); +To run the integration tests, use the following commands: - Page findByAge(byte age, Pageable pageable); -} +```sh +gradle test ``` -> From `repository/ProfileRepository.java` -Open the [`ProfileController`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/trycb/controller/ProfileController.java) class located in `controller` package and navigate to the `saveProfile` method. -This method accepts `Profile` objects deserialized by Spring Web from the body of an HTTP request. +## Appendix -```java - @PostMapping("/profile") - public ResponseEntity saveProfile(@RequestBody Profile profile) { - // the same endpoint can be used to create and save the object - profile = profileRepository.save(profile); - return ResponseEntity.status(HttpStatus.CREATED).body(profile); - } -``` -> *from `saveProfile` method of `controller/ProfileController.java`* +### Data Model -This object can be modified according to business requirements and then saved directly into the database using `ProfileRepository::save` method. -Because we used `@GeneratedValue` annotation on `id` field of our java model, Spring Data will automatically generate a document id when it is missing from the request. This allows clients to use `/profile` endpoint both to update existing profiles and create new records. -To achieve this, a client needs to submit a Profile object without the id field. +For this quickstart, we use three collections, `airport`, `airline` and `routes` that contain sample airports, airlines and airline routes respectively. The routes collection connects the airports and airlines as seen in the figure below. We use these connections in the quickstart to generate airports that are directly connected and airlines connecting to a destination airport. Note that these are just examples to highlight how you can use SQL++ queries to join the collections. -### Get Profile by Key -Navigate to the `getProfileById` method in [`ProfileController`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/trycb/controller/ProfileController.java) class. -This method handles client requests to retrieve a single profile by its unique id. -Sent by the client UUID is passed to the standard `findById` method of `ProfileRepository`, which returns an `Optional` with requested profile: +![travel-sample data model](./assets/images/travel_sample_data_model.png) -```java -Profile result = profileRepository.findById(id).orElse(null); -``` -> *from getProfileById method of controller/ProfileController.java* +### Extending API by Adding New Entity -### Search profiles by text -Although Couchbase provides [powerful full-text search capabilities out of the box](https://www.couchbase.com/products/full-text-search), in this demo we use classic `LIKE` query for our profile search endpoint. -Navigate to `listProfiles` method of [Profile Controller](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/trycb/controller/ProfileController.java). -The endpoint uses customized `findByText` method of [Profile Repository](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/trycb/repository/ProfileRepository.java): -```java -result = profileRepository.findByText(query, pageRequest).toList(); -``` -> *from `listProfiles` method in `controller/ProfileController.java`* +If you would like to add another entity to the APIs, these are the steps to follow: -The `ProfileRepository::findByQueryMethod` is generated automatically using provided in `@Query` annotation [SpEL](https://docs.spring.io/spring-integration/docs/5.3.0.RELEASE/reference/html/spel.html) template in SQL++: -```java - @Query("#{#n1ql.selectEntity} WHERE firstName LIKE '%' || $1 || '%' OR lastName LIKE '%' || $1 || '%' OR address LIKE '%' || $1 || '%'") - Page findByQuery(String query, Pageable pageable); -``` -> *definition of `findByQuery` method in `repository/ProfileRepository.java`* +- You can create the collection using the [SDK](https://docs.couchbase.com/sdk-api/couchbase-java-client-3.5.2/com/couchbase/client/java/Collection.html#createScope-java.lang.String-) or via the [Couchbase Server interface](https://docs.couchbase.com/cloud/n1ql/n1ql-language-reference/createcollection.html). +- Create a new entity class in the `models` package similar to the existing entity classes like `Airport.java`. +- Define the controller in a new file in the `controllers` folder similar to the existing classes like `AirportController.java`. +- Define the service in a new file in the `services` folder similar to the existing classes like `AirportService.java`. +- Define the repository in a new file in the `repositories` folder similar to the existing classes like `AirportRepository.java`. -You can find out more about SQL++ in Spring Data in the [connector documentation](https://docs.spring.io/spring-data/couchbase/docs/current/reference/html/#couchbase.repository.querying). +### Running Self-Managed Couchbase Cluster -### DELETE Profile -Navigate to the `deleteProfile` method in the [Profile Controller](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/trycb/controller/ProfileController.java). -We only need the `Key` or id from the user to remove a document using a basic key-value operation. +If you are running this quickstart with a self-managed Couchbase cluster, you need to [load](https://docs.couchbase.com/server/current/manage/manage-settings/install-sample-buckets.html) the travel-sample data bucket in your cluster and generate the credentials for the bucket. -```java - profileRepository.deleteById(id); -``` +You need to update the connection string and the credentials in the `application.properties` file in the `src/main/resources` folder. + +Note: Couchbase Server version 7 or higher must be installed and running before running the Spring Boot Java app. + +### Swagger Documentation + +Swagger documentation provides a clear view of the API including endpoints, HTTP methods, request parameters, and response objects. + +Click on an individual endpoint to expand it and see detailed information. This includes the endpoint's description, possible response status codes, and the request parameters it accepts. + +#### Trying Out the API + +You can try out an API by clicking on the "Try it out" button next to the endpoints. + +- Parameters: If an endpoint requires parameters, Swagger UI provides input boxes for you to fill in. This could include path parameters, query strings, headers, or the body of a POST/PUT request. -> *from `deleteProfile` method of controller/ProfileController.java* +- Execution: Once you've inputted all the necessary parameters, you can click the "Execute" button to make a live API call. Swagger UI will send the request to the API and display the response directly in the documentation. This includes the response code, response headers, and response body. +#### Models -## Conclusion -Setting up a basic REST API in Spring Data with Couchbase is fairly simple. This project, when run with Couchbase Server 7 installed creates a collection in Couchbase, an index for our parameterized [N1QL query](https://docs.couchbase.com/java-sdk/current/howtos/n1ql-queries-with-sdk.html), and showcases basic CRUD operations needed in most applications. +Swagger documents the structure of request and response bodies using models. These models define the expected data structure using JSON schema and are extremely helpful in understanding what data to send and expect. diff --git a/assets/images/app-startup-spring-data.png b/assets/images/app-startup-spring-data.png new file mode 100644 index 0000000..98b6bcc Binary files /dev/null and b/assets/images/app-startup-spring-data.png differ diff --git a/assets/images/swagger-documentation-spring-data.png b/assets/images/swagger-documentation-spring-data.png new file mode 100644 index 0000000..b92389c Binary files /dev/null and b/assets/images/swagger-documentation-spring-data.png differ diff --git a/assets/images/travel_sample_data_model.png b/assets/images/travel_sample_data_model.png new file mode 100644 index 0000000..84c750d Binary files /dev/null and b/assets/images/travel_sample_data_model.png differ diff --git a/build.gradle b/build.gradle index 160b6ca..387f345 100644 --- a/build.gradle +++ b/build.gradle @@ -1,22 +1,41 @@ plugins { - id 'org.springframework.boot' version '2.7.0' - id 'io.spring.dependency-management' version '1.0.11.RELEASE' + id 'org.springframework.boot' version '3.2.2' + id 'io.spring.dependency-management' version '1.1.0' id 'java' } -group = 'trycb' +group = 'org.couchbase.quickstart.springdata' version = '0.0.1-SNAPSHOT' -sourceCompatibility = '1.8' +archivesBaseName = 'java-springdata-quickstart' repositories { mavenCentral() } +jar { + manifest { + attributes 'Main-Class': 'org.couchbase.quickstart.springdata.Application' + } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-couchbase' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.data:spring-data-couchbase' + implementation 'org.springframework.boot:spring-boot-devtools' + + implementation 'jakarta.persistence:jakarta.persistence-api' + implementation 'jakarta.servlet:jakarta.servlet-api' + + // lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok:' + testAnnotationProcessor 'org.projectlombok:lombok' + + // swagger 3 springdoc openapi 2 + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' - implementation 'org.springdoc:springdoc-openapi-ui:1.6.15' testImplementation 'org.springframework.boot:spring-boot-starter-test' } @@ -24,3 +43,4 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 41d9927..d64cd49 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 00e33ed..a80b22c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +131,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,11 +198,15 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ @@ -205,6 +214,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index ac1b06f..6689b85 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle b/settings.gradle index b34111e..4263232 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1 @@ -rootProject.name = 'example' +rootProject.name = 'org.couchbase.quickstart.springdata' diff --git a/src/main/java/org/couchbase/quickstart/springdata/Application.java b/src/main/java/org/couchbase/quickstart/springdata/Application.java new file mode 100644 index 0000000..21dc560 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/Application.java @@ -0,0 +1,42 @@ +package org.couchbase.quickstart.springdata; + +import static org.couchbase.quickstart.springdata.config.SpringDocConstants.DESCRIPTION; +import static org.couchbase.quickstart.springdata.config.SpringDocConstants.TITLE; +import static org.couchbase.quickstart.springdata.config.SpringDocConstants.VERSION; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.web.filter.ForwardedHeaderFilter; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import lombok.extern.slf4j.Slf4j; + +/** + * This example application demonstrates using + * Spring Data with Couchbase. + **/ + +@Slf4j +@SpringBootApplication(exclude = SecurityAutoConfiguration.class, proxyBeanMethods = false) +@OpenAPIDefinition(info = @Info(title = TITLE, version = VERSION, description = DESCRIPTION)) +public class Application implements CommandLineRunner { + + @Override + public void run(String... args) throws Exception { + log.info("Application started successfully"); + } + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + @Bean + ForwardedHeaderFilter forwardedHeaderFilter() { + return new ForwardedHeaderFilter(); + } + +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/advice/ApplicationExceptionHandler.java b/src/main/java/org/couchbase/quickstart/springdata/advice/ApplicationExceptionHandler.java new file mode 100644 index 0000000..15a4d39 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/advice/ApplicationExceptionHandler.java @@ -0,0 +1,29 @@ +package org.couchbase.quickstart.springdata.advice; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +@RestControllerAdvice +public class ApplicationExceptionHandler { + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public Map handleValidationExceptions( + MethodArgumentNotValidException ex) { + Map errorMap = new HashMap<>(); + + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errorMap.put(fieldName, errorMessage); + }); + + return errorMap; + } +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/config/CouchbaseConfiguration.java b/src/main/java/org/couchbase/quickstart/springdata/config/CouchbaseConfiguration.java new file mode 100644 index 0000000..9048297 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/config/CouchbaseConfiguration.java @@ -0,0 +1,84 @@ +package org.couchbase.quickstart.springdata.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.couchbase.config.AbstractCouchbaseConfiguration; +import org.springframework.data.couchbase.repository.config.EnableCouchbaseRepositories; + +import com.couchbase.client.core.error.BucketNotFoundException; +import com.couchbase.client.java.Bucket; +import com.couchbase.client.java.Cluster; +import com.couchbase.client.java.env.ClusterEnvironment; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +@EnableCouchbaseRepositories +public class CouchbaseConfiguration extends AbstractCouchbaseConfiguration { + + @Value("#{systemEnvironment['DB_CONN_STR'] ?: '${spring.couchbase.bootstrap-hosts:localhost}'}") + private String host; + + @Value("#{systemEnvironment['DB_USERNAME'] ?: '${spring.couchbase.bucket.user:Administrator}'}") + private String username; + + @Value("#{systemEnvironment['DB_PASSWORD'] ?: '${spring.couchbase.bucket.password:password}'}") + private String password; + + @Value("${spring.couchbase.bucket.name:travel-sample}") + private String bucketName; + + @Override + public String getConnectionString() { + return host; + } + + @Override + public String getUserName() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getBucketName() { + return bucketName; + } + + @Override + public String typeKey() { + return "type"; + } + + @Override + @Bean(destroyMethod = "disconnect") + public Cluster couchbaseCluster(ClusterEnvironment couchbaseClusterEnvironment) { + try { + log.debug("Connecting to Couchbase cluster at " + host); + return Cluster.connect(getConnectionString(), getUserName(), getPassword()); + } catch (Exception e) { + log.error("Error connecting to Couchbase cluster", e); + throw e; + } + } + + @Bean + public Bucket getCouchbaseBucket(Cluster cluster) { + try { + if (!cluster.buckets().getAllBuckets().containsKey(getBucketName())) { + log.error("Bucket with name {} does not exist. Creating it now", getBucketName()); + throw new BucketNotFoundException(bucketName); + } + return cluster.bucket(getBucketName()); + } catch (Exception e) { + log.error("Error getting bucket", e); + throw e; + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/couchbase/quickstart/springdata/config/SpringDocConstants.java b/src/main/java/org/couchbase/quickstart/springdata/config/SpringDocConstants.java new file mode 100644 index 0000000..3d144c8 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/config/SpringDocConstants.java @@ -0,0 +1,11 @@ +package org.couchbase.quickstart.springdata.config; + +public class SpringDocConstants { + + private SpringDocConstants() {} + + public static final String TITLE = "Quickstart in Couchbase with Spring Data"; + public static final String VERSION = "2.0"; + public static final String DESCRIPTION = "

A quickstart API using Java and Spring Data with Couchbase and travel-sample data

We have a visual representation of the API documentation using Swagger which allows you to interact with the API's endpoints directly through the browser. It provides a clear view of the API including endpoints, HTTP methods, request parameters, and response objects.

Click on an individual endpoint to expand it and see detailed information. This includes the endpoint's description, possible response status codes, and the request parameters it accepts.

Trying Out the API

You can try out an API by clicking on the \"Try it out\" button next to the endpoints.

  • Parameters: If an endpoint requires parameters, Swagger UI provides input boxes for you to fill in. This could include path parameters, query strings, headers, or the body of a POST/PUT request.
  • Execution: Once you've inputted all the necessary parameters, you can click the \"Execute\" button to make a live API call. Swagger UI will send the request to the API and display the response directly in the documentation. This includes the response code, response headers, and response body.

Models

Swagger documents the structure of request and response bodies using models. These models define the expected data structure using JSON schema and are extremely helpful in understanding what data to send and expect.

For details on the API, please check the tutorial on the Couchbase Developer Portal: https://developer.couchbase.com/tutorial-quickstart-java-spring-boot

"; + +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/controller/AirlineController.java b/src/main/java/org/couchbase/quickstart/springdata/controller/AirlineController.java new file mode 100644 index 0000000..49ed240 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/controller/AirlineController.java @@ -0,0 +1,183 @@ +package org.couchbase.quickstart.springdata.controller; + +import java.util.Optional; + +import org.couchbase.quickstart.springdata.models.Airline; +import org.couchbase.quickstart.springdata.services.AirlineService; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.couchbase.client.core.error.DocumentExistsException; +import com.couchbase.client.core.error.DocumentNotFoundException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api/v1/airline") +public class AirlineController { + + private final AirlineService airlineService; + + public AirlineController(AirlineService airlineService) { + this.airlineService = airlineService; + } + + // All Errors + private static final String INTERNAL_SERVER_ERROR = "Internal server error"; + private static final String DOCUMENT_NOT_FOUND = "Document not found"; + private static final String DOCUMENT_ALREADY_EXISTS = "Document already exists"; + + @GetMapping("/{id}") + @Operation(summary = "Get an airline by ID", description = "Get Airline by specified ID.\n\nThis provides an example of using Key Value operations in Couchbase to retrieve a document with a specified ID. \n\n Code: [`controllers/AirlineController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirlineController.java) \n File: `AirlineController.java` \n Method: `getAirline`", tags = { + "Airline" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Airline found"), + @ApiResponse(responseCode = "404", description = "Airline not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "id", description = "Airline ID", required = true, example = "airline_10") + public ResponseEntity getAirline(@PathVariable String id) { + try { + Optional airline = airlineService.getAirlineById(id); + return airline.map(value -> new ResponseEntity<>(value, HttpStatus.OK)) + .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND)); + } catch (DocumentNotFoundException e) { + log.error(DOCUMENT_NOT_FOUND, e); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @PostMapping("/{id}") + @Operation(summary = "Create an airline", description = "Create an airline with the specified ID.\n\nThis provides an example of using Key Value operations in Couchbase to create a document with a specified ID. \n\n Code: [`controllers/AirlineController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirlineController.java) \n File: `AirlineController.java` \n Method: `createAirline`", tags = { + "Airline" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Airline created"), + @ApiResponse(responseCode = "409", description = "Airline already exists"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "id", description = "Airline ID", required = true, example = "airline_10") + public ResponseEntity createAirline(@Valid @RequestBody Airline airline) { + try { + Airline newAirline = airlineService.createAirline(airline); + return new ResponseEntity<>(newAirline, HttpStatus.CREATED); + } catch (DocumentExistsException e) { + log.error(DOCUMENT_ALREADY_EXISTS, e); + return new ResponseEntity<>(HttpStatus.CONFLICT); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @PutMapping("/{id}") + @Operation(summary = "Update an airline", description = "Update an airline with the specified ID.\n\nThis provides an example of using Key Value operations in Couchbase to update a document with a specified ID. \n\n Code: [`controllers/AirlineController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirlineController.java) \n File: `AirlineController.java` \n Method: `updateAirline`", tags = { + "Airline" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Airline updated"), + @ApiResponse(responseCode = "404", description = "Airline not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "id", description = "Airline ID", required = true, example = "airline_10") + public ResponseEntity updateAirline(@PathVariable String id, @Valid @RequestBody Airline airline) { + try { + Airline updatedAirline = airlineService.updateAirline(id, airline); + if (updatedAirline != null) { + return new ResponseEntity<>(updatedAirline, HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } catch (DocumentNotFoundException | DataRetrievalFailureException e) { + log.error(DOCUMENT_NOT_FOUND, e); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete an airline", description = "Delete an airline with the specified ID.\n\nThis provides an example of using Key Value operations in Couchbase to delete a document with a specified ID. \n\n Code: [`controllers/AirlineController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirlineController.java) \n File: `AirlineController.java` \n Method: `deleteAirline`", tags = { + "Airline" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Airline deleted"), + @ApiResponse(responseCode = "404", description = "Airline not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "id", description = "Airline ID", required = true, example = "airline_10") + public ResponseEntity deleteAirline(@PathVariable String id) { + try { + airlineService.deleteAirline(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (DocumentNotFoundException | DataRetrievalFailureException e) { + log.error(DOCUMENT_NOT_FOUND, e); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @GetMapping("/list") + @Operation(summary = "List all airlines by country", description = "List all airlines by country.\n\nThis provides an example of using N1QL queries in Couchbase to retrieve documents with a specified field value. \n\n Code: [`controllers/AirlineController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirlineController.java) \n File: `AirlineController.java` \n Method: `listAirlinesByCountry`", tags = { + "Airline" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Airlines found"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "country", description = "Country", required = false, example = "United States") + public ResponseEntity> listAirlinesByCountry( + @RequestParam(required = false) String country, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + try { + if (country == null || country.isEmpty()) { + Page airlines = airlineService.getAllAirlines(PageRequest.of(page, size)); + return new ResponseEntity<>(airlines, HttpStatus.OK); + } else { + Page airlines = airlineService.findByCountry(country, PageRequest.of(page, size)); + return new ResponseEntity<>(airlines, HttpStatus.OK); + } + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @GetMapping("/to-airport") + @Operation(summary = "List all airlines by desination airport", description = "List all airlines by destination airport.\n\nThis provides an example of using N1QL queries in Couchbase to retrieve documents with a specified field value. \n\n Code: [`controllers/AirlineController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirlineController.java) \n File: `AirlineController.java` \n Method: `listAirlinesByDestinationAirport`", tags = { + "Airline" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Airlines found"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "destinationAirport", description = "Destination Airport", required = false, example = "SFO") + public ResponseEntity> listAirlinesByDestinationAirport( + @RequestParam(required = false) String destinationAirport, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + try { + Page airlines = airlineService.findByDestinationAirport(destinationAirport, + PageRequest.of(page, size)); + + return new ResponseEntity<>(airlines, HttpStatus.OK); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/controller/AirportController.java b/src/main/java/org/couchbase/quickstart/springdata/controller/AirportController.java new file mode 100644 index 0000000..7c2c9c7 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/controller/AirportController.java @@ -0,0 +1,179 @@ +package org.couchbase.quickstart.springdata.controller; + +import java.util.Optional; + +import org.couchbase.quickstart.springdata.models.Airport; +import org.couchbase.quickstart.springdata.models.Route; +import org.couchbase.quickstart.springdata.services.AirportService; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.couchbase.client.core.error.DocumentExistsException; +import com.couchbase.client.core.error.DocumentNotFoundException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequestMapping("/api/v1/airport") +@Slf4j +public class AirportController { + + private final AirportService airportService; + + public AirportController(AirportService airportService) { + this.airportService = airportService; + } + + // All Errors + private static final String INTERNAL_SERVER_ERROR = "Internal server error"; + private static final String DOCUMENT_NOT_FOUND = "Document not found"; + private static final String DOCUMENT_ALREADY_EXISTS = "Document already exists"; + + @GetMapping("/{id}") + @Operation(summary = "Get an airport by ID", description = "Get Airport by specified ID.\n\nThis provides an example of using Key Value operations in Couchbase to retrieve a document with a specified ID. \n\n Code: [`controllers/AirportController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirportController.java) \n File: `AirportController.java` \n Method: `getAirport`", tags = { + "Airport" }) + @Parameter(name = "id", description = "The ID of the airport to retrieve", required = true, example = "airport_1254") + public ResponseEntity getAirport(@PathVariable String id) { + try { + Optional airport = airportService.getAirportById(id); + return airport.map(value -> new ResponseEntity<>(value, HttpStatus.OK)) + .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND)); + } catch (DocumentNotFoundException | DataRetrievalFailureException e) { + log.error(DOCUMENT_NOT_FOUND, e); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @PostMapping("/{id}") + @Operation(summary = "Create an airport", description = "Create an airport with the specified ID.\n\nThis provides an example of using Key Value operations in Couchbase to create a document with a specified ID. \n\n Code: [`controllers/AirportController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirportController.java) \n File: `AirportController.java` \n Method: `createAirport`", tags = { + "Airport" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Airport created"), + @ApiResponse(responseCode = "409", description = "Airport already exists"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + @Parameter(name = "id", description = "The ID of the airport to create", required = true, example = "airport_1254") + public ResponseEntity createAirport(@PathVariable String id, @Valid @RequestBody Airport airport) { + try { + Airport newAirport = airportService.createAirport(airport); + return new ResponseEntity<>(newAirport, HttpStatus.CREATED); + } catch (DocumentExistsException e) { + log.error(DOCUMENT_ALREADY_EXISTS, e); + return new ResponseEntity<>(HttpStatus.CONFLICT); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + + } + + @PutMapping("/{id}") + @Operation(summary = "Update an airport", description = "Update an airport with the specified ID.\n\nThis provides an example of using Key Value operations in Couchbase to update a document with a specified ID. \n\n Code: [`controllers/AirportController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirportController.java) \n File: `AirportController.java` \n Method: `updateAirport`", tags = { + "Airport" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Airport updated"), + @ApiResponse(responseCode = "404", description = "Airport not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + @Parameter(name = "id", description = "The ID of the airport to update", required = true, example = "airport_1254") + public ResponseEntity updateAirport(@PathVariable String id, @Valid @RequestBody Airport airport) { + try { + Airport updatedAirport = airportService.updateAirport(id, airport); + if (updatedAirport != null) { + return new ResponseEntity<>(updatedAirport, HttpStatus.OK); + } else { + log.error(DOCUMENT_NOT_FOUND); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } catch (DocumentNotFoundException | DataRetrievalFailureException e) { + log.error(DOCUMENT_NOT_FOUND, e); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete an airport", description = "Delete an airport with the specified ID.\n\nThis provides an example of using Key Value operations in Couchbase to delete a document with a specified ID. \n\n Code: [`controllers/AirportController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirportController.java) \n File: `AirportController.java` \n Method: `deleteAirport`", tags = { + "Airport" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Airport deleted"), + @ApiResponse(responseCode = "404", description = "Airport not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + @Parameter(name = "id", description = "The ID of the airport to delete", required = true, example = "airport_1254") + public ResponseEntity deleteAirport(@PathVariable String id) { + try { + airportService.deleteAirport(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (DocumentNotFoundException e) { + log.error(DOCUMENT_NOT_FOUND, e); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @GetMapping("/list") + @Operation(summary = "List all airports", description = "List all airports in the database.\n\nThis provides an example of using N1QL to query all documents in a bucket. \n\n Code: [`controllers/AirportController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirportController.java) \n File: `AirportController.java` \n Method: `listAirports`", tags = { + "Airport" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "List of airports"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public ResponseEntity> listAirports(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + try { + Page airports = airportService.getAllAirports(PageRequest.of(page, size)); + return new ResponseEntity<>(airports, HttpStatus.OK); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @GetMapping("/direct-connections") + @Operation(summary = "List of direct connections to an airport", description = "List of direct connections to an airport.\n\nThis provides an example of using N1QL to query all documents in a bucket. \n\n Code: [`controllers/AirportController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/AirportController.java) \n File: `AirportController.java` \n Method: `listDirectConnections`", tags = { + "Airport" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "List of direct connections"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + @Parameter(name = "airportCode", description = "The airport code to list direct connections", required = true, example = "SFO") + public ResponseEntity> listDirectConnections( + @RequestParam(required = true) String airportCode, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + try { + Page airports = airportService.getDirectConnections(airportCode, PageRequest.of(page, size)); + Page directConnections = airports.map(Route::getDestinationAirport); + return new ResponseEntity<>(directConnections, HttpStatus.OK); + + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/controller/RouteController.java b/src/main/java/org/couchbase/quickstart/springdata/controller/RouteController.java new file mode 100644 index 0000000..8c21fb9 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/controller/RouteController.java @@ -0,0 +1,156 @@ +package org.couchbase.quickstart.springdata.controller; + +import java.util.Optional; + +import org.couchbase.quickstart.springdata.models.Route; +import org.couchbase.quickstart.springdata.services.RouteService; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.couchbase.client.core.error.DocumentExistsException; +import com.couchbase.client.core.error.DocumentNotFoundException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequestMapping("/api/v1/route") +@Slf4j +public class RouteController { + + private final RouteService routeService; + + public RouteController(RouteService routeService) { + this.routeService = routeService; + } + + // All Errors + private static final String INTERNAL_SERVER_ERROR = "Internal server error"; + private static final String DOCUMENT_NOT_FOUND = "Document not found"; + private static final String DOCUMENT_ALREADY_EXISTS = "Document already exists"; + + @GetMapping("/{id}") + @Operation(summary = "Get a route by ID", description = "Get Route by specified ID.\n\nThis provides an example of using Key Value operations in Couchbase to retrieve a document with a specified ID. \n\n Code: [`controllers/RouteController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/RouteController.java) \n File: `RouteController.java` \n Method: `getRoute`", tags = { + "Route" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Route found"), + @ApiResponse(responseCode = "404", description = "Route not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "id", description = "Route ID", required = true, example = "route_10000") + public ResponseEntity getRoute(@PathVariable String id) { + try { + Optional route = routeService.getRouteById(id); + return route.map(value -> new ResponseEntity<>(value, HttpStatus.OK)) + .orElseGet(() -> new ResponseEntity<>(HttpStatus.NOT_FOUND)); + } catch (DocumentNotFoundException e) { + log.error(DOCUMENT_NOT_FOUND, e); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @PostMapping("/{id}") + @Operation(summary = "Create a route", description = "Create a new route.\n\nThis provides an example of using Key Value operations in Couchbase to create a new document with a specified ID. \n\n Code: [`controllers/RouteController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/RouteController.java) \n File: `RouteController.java` \n Method: `createRoute`", tags = { + "Route" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "Route created"), + @ApiResponse(responseCode = "409", description = "Route already exists"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "id", description = "Route ID", required = true, example = "route_10000") + public ResponseEntity createRoute(@Valid @RequestBody Route route) { + try { + Route newRoute = routeService.createRoute(route); + return new ResponseEntity<>(newRoute, HttpStatus.CREATED); + } catch (DocumentExistsException e) { + log.error(DOCUMENT_ALREADY_EXISTS, e); + return new ResponseEntity<>(HttpStatus.CONFLICT); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + + } + + @PutMapping("/{id}") + @Operation(summary = "Update a route", description = "Update a route.\n\nThis provides an example of using Key Value operations in Couchbase to update a document with a specified ID. \n\n Code: [`controllers/RouteController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/RouteController.java) \n File: `RouteController.java` \n Method: `updateRoute`", tags = { + "Route" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Route updated"), + @ApiResponse(responseCode = "404", description = "Route not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "id", description = "Route ID", required = true, example = "route_10000") + public ResponseEntity updateRoute(@PathVariable String id, @Valid @RequestBody Route route) { + try { + Route updatedRoute = routeService.updateRoute(id, route); + if (updatedRoute != null) { + return new ResponseEntity<>(updatedRoute, HttpStatus.OK); + } else { + log.error(DOCUMENT_NOT_FOUND); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } catch (DocumentNotFoundException e) { + log.error(DOCUMENT_NOT_FOUND, e); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete a route", description = "Delete a route.\n\nThis provides an example of using Key Value operations in Couchbase to delete a document with a specified ID. \n\n Code: [`controllers/RouteController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/RouteController.java) \n File: `RouteController.java` \n Method: `deleteRoute`", tags = { + "Route" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "Route deleted"), + @ApiResponse(responseCode = "404", description = "Route not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + @Parameter(name = "id", description = "Route ID", required = true, example = "route_10000") + public ResponseEntity deleteRoute(@PathVariable String id) { + try { + routeService.deleteRoute(id); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } catch (DocumentNotFoundException | DataRetrievalFailureException e) { + log.error(DOCUMENT_NOT_FOUND, e); + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @GetMapping("/list") + @Operation(summary = "List all routes", description = "List all routes.\n\nThis provides an example of using N1QL queries in Couchbase to retrieve all documents of a specified type. \n\n Code: [`controllers/RouteController.java`](https://github.com/couchbase-examples/java-springdata-quickstart/blob/main/src/main/java/org/couchbase/quickstart/springdata/controllers/RouteController.java) \n File: `RouteController.java` \n Method: `listRoutes`", tags = { + "Route" }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Routes found"), + @ApiResponse(responseCode = "500", description = "Internal server error") }) + public ResponseEntity> listRoutes(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + try { + Page routes = routeService.getAllRoutes(PageRequest.of(page, size)); + return new ResponseEntity<>(routes, HttpStatus.OK); + } catch (Exception e) { + log.error(INTERNAL_SERVER_ERROR, e); + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/controller/SwaggerRedirectController.java b/src/main/java/org/couchbase/quickstart/springdata/controller/SwaggerRedirectController.java new file mode 100644 index 0000000..8d290cb --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/controller/SwaggerRedirectController.java @@ -0,0 +1,17 @@ +package org.couchbase.quickstart.springdata.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.servlet.view.RedirectView; + +@Controller +public class SwaggerRedirectController { + + @GetMapping(value = { "/", "/swagger-ui", "/swagger-ui/" }) + @ResponseStatus(HttpStatus.MOVED_PERMANENTLY) + public RedirectView redirect() { + return new RedirectView("/swagger-ui/index.html"); + } +} \ No newline at end of file diff --git a/src/main/java/org/couchbase/quickstart/springdata/models/Airline.java b/src/main/java/org/couchbase/quickstart/springdata/models/Airline.java new file mode 100644 index 0000000..d5452da --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/models/Airline.java @@ -0,0 +1,49 @@ +package org.couchbase.quickstart.springdata.models; + +import java.io.Serializable; + +import org.springframework.data.annotation.Id; +import org.springframework.data.couchbase.core.mapping.Field; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class Airline implements Serializable { + + @Id + @NotBlank(message = "Id is mandatory") + private String id; + + @Field + @NotBlank(message = "Type is mandatory") + private String type; + + @Field + @NotBlank(message = "Name is mandatory") + private String name; + + @Field + @NotBlank(message = "IATA code is mandatory") + private String iata; + + @Field + @NotBlank(message = "ICAO code is mandatory") + private String icao; + + @Field + @NotBlank(message = "Callsign is mandatory") + private String callsign; + + @Field + @NotBlank(message = "Country is mandatory") + private String country; + + +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/models/Airport.java b/src/main/java/org/couchbase/quickstart/springdata/models/Airport.java new file mode 100644 index 0000000..332443c --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/models/Airport.java @@ -0,0 +1,69 @@ +package org.couchbase.quickstart.springdata.models; + +import java.io.Serializable; + +import org.springframework.data.annotation.Id; +import org.springframework.data.couchbase.core.mapping.Field; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class Airport implements Serializable { + + @Id + @NotBlank(message = "Id is mandatory") + private String id; + + @NotBlank(message = "Type is mandatory") + private String type; + + @NotBlank(message = "Airport name is mandatory") + @Field("airportname") + private String airportName; + + @NotBlank(message = "City is mandatory") + private String city; + + @NotBlank(message = "Country is mandatory") + private String country; + + @NotBlank(message = "FAA code is mandatory") + @Pattern(regexp = "^[A-Z]{3}$", message = "FAA code must be a 3-letter uppercase code") + private String faa; + + @NotBlank(message = "ICAO code is mandatory") + @Pattern(regexp = "^[A-Z]{4}$", message = "ICAO code must be a 4-letter uppercase code") + private String icao; + + @NotBlank(message = "Timezone is mandatory") + private String tz; + + @Valid // To validate the embedded Geo object + private Geo geo; + + @AllArgsConstructor + @NoArgsConstructor + @Data + @Builder + public static class Geo implements Serializable { + + @NotNull(message = "Altitude is mandatory") + private Double alt; + + @NotNull(message = "Latitude is mandatory") + private Double lat; + + @NotNull(message = "Longitude is mandatory") + private Double lon; + } +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/models/RestResponsePage.java b/src/main/java/org/couchbase/quickstart/springdata/models/RestResponsePage.java new file mode 100644 index 0000000..4a17518 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/models/RestResponsePage.java @@ -0,0 +1,28 @@ +package org.couchbase.quickstart.springdata.models; + +import java.util.List; + +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +public class RestResponsePage extends PageImpl { + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public RestResponsePage(@JsonProperty("content") List content, + @JsonProperty("number") int number, + @JsonProperty("size") int size, + @JsonProperty("totalElements") Long totalElements, + @JsonProperty("pageable") JsonNode pageable, + @JsonProperty("last") boolean last, + @JsonProperty("totalPages") int totalPages, + @JsonProperty("sort") JsonNode sort, + @JsonProperty("first") boolean first, + @JsonProperty("numberOfElements") int numberOfElements) { + + super(content, PageRequest.of(number, size), totalElements); + } + +} \ No newline at end of file diff --git a/src/main/java/org/couchbase/quickstart/springdata/models/Route.java b/src/main/java/org/couchbase/quickstart/springdata/models/Route.java new file mode 100644 index 0000000..3c62036 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/models/Route.java @@ -0,0 +1,71 @@ +package org.couchbase.quickstart.springdata.models; + +import java.io.Serializable; +import java.util.List; + +import org.springframework.data.annotation.Id; +import org.springframework.data.couchbase.core.mapping.Field; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Data +@Builder +public class Route implements Serializable { + + @Id + @NotBlank(message = "Id is mandatory") + private String id; + + @NotBlank(message = "Type is mandatory") + private String type; + + @NotBlank(message = "Airline is mandatory") + private String airline; + + @NotBlank(message = "Airline ID is mandatory") + @Field("airlineid") + private String airlineId; + + @NotBlank(message = "Source airport is mandatory") + @Field("sourceairport") + private String sourceAirport; + + @NotBlank(message = "Destination airport is mandatory") + @Field("destinationairport") + private String destinationAirport; + + @NotNull(message = "Stops is mandatory") + private Integer stops; + + @NotBlank(message = "Equipment is mandatory") + private String equipment; + + @Valid // To validate the list of schedules + private List schedule; + + @NotNull(message = "Distance is mandatory") + private Double distance; + + @AllArgsConstructor + @NoArgsConstructor + @Data + @Builder + public static class Schedule implements Serializable { + @NotNull(message = "Day is mandatory") + private Integer day; + + @NotBlank(message = "Flight is mandatory") + private String flight; + + @NotBlank(message = "UTC is mandatory") + private String utc; + } +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/repository/AirlineRepository.java b/src/main/java/org/couchbase/quickstart/springdata/repository/AirlineRepository.java new file mode 100644 index 0000000..c8edaaa --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/repository/AirlineRepository.java @@ -0,0 +1,32 @@ +package org.couchbase.quickstart.springdata.repository; + +import org.couchbase.quickstart.springdata.models.Airline; +import org.springframework.data.couchbase.repository.Collection; +import org.springframework.data.couchbase.repository.CouchbaseRepository; +import org.springframework.data.couchbase.repository.Query; +import org.springframework.data.couchbase.repository.ScanConsistency; +import org.springframework.data.couchbase.repository.Scope; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.couchbase.client.java.query.QueryScanConsistency; + +@Scope("inventory") +@Collection("airline") +@Repository +@ScanConsistency(query = QueryScanConsistency.REQUEST_PLUS) +public interface AirlineRepository extends CouchbaseRepository { + + @Query("SELECT META(air).id AS __id, air.callsign, air.country, air.iata, air.icao, air.id, air.name, air.type " + + "FROM airline AS air WHERE air.country = $1") + Page findByCountry(String country, Pageable pageable); + + @Query("SELECT META(air).id AS __id, air.callsign, air.country, air.iata, air.icao, air.id, air.name, air.type " + + "FROM (SELECT DISTINCT META(airline).id AS airlineId FROM route " + + "JOIN airline ON route.airlineid = META(airline).id " + + "WHERE route.destinationairport = $1) AS subquery " + + "JOIN airline AS air ON META(air).id = subquery.airlineId") + Page findByDestinationAirport(String destinationAirport, Pageable pageable); + +} \ No newline at end of file diff --git a/src/main/java/org/couchbase/quickstart/springdata/repository/AirportRepository.java b/src/main/java/org/couchbase/quickstart/springdata/repository/AirportRepository.java new file mode 100644 index 0000000..b552463 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/repository/AirportRepository.java @@ -0,0 +1,31 @@ +package org.couchbase.quickstart.springdata.repository; + +import org.couchbase.quickstart.springdata.models.Airport; +import org.couchbase.quickstart.springdata.models.Route; +import org.springframework.data.couchbase.repository.Collection; +import org.springframework.data.couchbase.repository.CouchbaseRepository; +import org.springframework.data.couchbase.repository.Query; +import org.springframework.data.couchbase.repository.ScanConsistency; +import org.springframework.data.couchbase.repository.Scope; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.couchbase.client.java.query.QueryScanConsistency; + +@Scope("inventory") +@Collection("airport") +@Repository +@ScanConsistency(query = QueryScanConsistency.REQUEST_PLUS) +public interface AirportRepository extends CouchbaseRepository { + + @Query("SELECT META(airport).id as __id,airport.* FROM airport") + Page findAll(Pageable pageable); + + @Query("SELECT DISTINCT META(route).id as __id,route.* " + + "FROM airport as airport " + + "JOIN route as route ON airport.faa = route.sourceairport " + + "WHERE airport.faa = $1 AND route.stops = 0") + Page getDirectConnections(String targetAirportCode, Pageable pageable); + +} \ No newline at end of file diff --git a/src/main/java/org/couchbase/quickstart/springdata/repository/RouteRepository.java b/src/main/java/org/couchbase/quickstart/springdata/repository/RouteRepository.java new file mode 100644 index 0000000..858aac5 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/repository/RouteRepository.java @@ -0,0 +1,24 @@ +package org.couchbase.quickstart.springdata.repository; + +import org.couchbase.quickstart.springdata.models.Route; +import org.springframework.data.couchbase.repository.Collection; +import org.springframework.data.couchbase.repository.CouchbaseRepository; +import org.springframework.data.couchbase.repository.Query; +import org.springframework.data.couchbase.repository.ScanConsistency; +import org.springframework.data.couchbase.repository.Scope; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.couchbase.client.java.query.QueryScanConsistency; + +@Scope("inventory") +@Collection("route") +@Repository +@ScanConsistency(query = QueryScanConsistency.REQUEST_PLUS) +public interface RouteRepository extends CouchbaseRepository { + + @Query("SELECT META(route).id as __id,route.* FROM route") + Page findAll(Pageable pageable); + +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/services/AirlineService.java b/src/main/java/org/couchbase/quickstart/springdata/services/AirlineService.java new file mode 100644 index 0000000..3607ff5 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/services/AirlineService.java @@ -0,0 +1,54 @@ +package org.couchbase.quickstart.springdata.services; + + +import java.util.Optional; + +import org.couchbase.quickstart.springdata.models.Airline; +import org.couchbase.quickstart.springdata.repository.AirlineRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +public class AirlineService { + + private final AirlineRepository airlineRepository; + + public AirlineService(AirlineRepository airlineRepository) { + this.airlineRepository = airlineRepository; + } + + public Page getAllAirlines(Pageable pageable) { + return airlineRepository.findAll(pageable); + } + + public Optional getAirlineById(String id) { + return airlineRepository.findById(id); + } + + public Airline saveAirline(Airline airline) { + return airlineRepository.save(airline); + } + + public void deleteAirline(String id) { + airlineRepository.deleteById(id); + } + + public Airline createAirline(Airline airline) { + return airlineRepository.save(airline); + } + + public Airline updateAirline(String id, Airline airline) { + airline.setId(id); + return airlineRepository.save(airline); + } + + public Page findByCountry(String country, Pageable pageable) { + return airlineRepository.findByCountry(country,pageable); + } + + public Page findByDestinationAirport(String destinationAirport, Pageable pageable) { + return airlineRepository.findByDestinationAirport(destinationAirport, pageable); + } + +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/services/AirportService.java b/src/main/java/org/couchbase/quickstart/springdata/services/AirportService.java new file mode 100644 index 0000000..a00482c --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/services/AirportService.java @@ -0,0 +1,50 @@ +package org.couchbase.quickstart.springdata.services; + +import java.util.Optional; + +import org.couchbase.quickstart.springdata.models.Airport; +import org.couchbase.quickstart.springdata.models.Route; +import org.couchbase.quickstart.springdata.repository.AirportRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +public class AirportService { + + private final AirportRepository airportRepository; + + public AirportService(AirportRepository airportRepository) { + this.airportRepository = airportRepository; + } + + public Page getAllAirports(Pageable pageable) { + return airportRepository.findAll(pageable); + } + + public Optional getAirportById(String id) { + return airportRepository.findById(id); + } + + public Airport saveAirport(Airport airport) { + return airportRepository.save(airport); + } + + public void deleteAirport(String id) { + airportRepository.deleteById(id); + } + + public Airport createAirport(Airport airport) { + return airportRepository.save(airport); + } + + public Airport updateAirport(String id, Airport airport) { + airport.setId(id); + return airportRepository.save(airport); + } + + public Page getDirectConnections(String id, Pageable pageable) { + return airportRepository.getDirectConnections(id, pageable); + } + +} diff --git a/src/main/java/org/couchbase/quickstart/springdata/services/RouteService.java b/src/main/java/org/couchbase/quickstart/springdata/services/RouteService.java new file mode 100644 index 0000000..38fee30 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/services/RouteService.java @@ -0,0 +1,45 @@ +package org.couchbase.quickstart.springdata.services; + +import java.util.Optional; + +import org.couchbase.quickstart.springdata.models.Route; +import org.couchbase.quickstart.springdata.repository.RouteRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +public class RouteService { + + private final RouteRepository routeRepository; + + public RouteService(RouteRepository routeRepository) { + this.routeRepository = routeRepository; + } + + public Page getAllRoutes(Pageable pageable) { + return routeRepository.findAll(pageable); + } + + public Optional getRouteById(String id) { + return routeRepository.findById(id); + } + + public Route saveRoute(Route route) { + return routeRepository.save(route); + } + + public void deleteRoute(String id) { + routeRepository.deleteById(id); + } + + public Route createRoute(Route route) { + return routeRepository.save(route); + } + + public Route updateRoute(String id, Route route) { + route.setId(id); + return routeRepository.save(route); + } + +} diff --git a/src/main/java/trycb/ExampleApplication.java b/src/main/java/trycb/ExampleApplication.java deleted file mode 100644 index 232d16f..0000000 --- a/src/main/java/trycb/ExampleApplication.java +++ /dev/null @@ -1,23 +0,0 @@ -package trycb; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.Bean; -import org.springframework.web.filter.ForwardedHeaderFilter; - -/** - * This example application demonstrates using - * Spring Data with Couchbase. - **/ -@SpringBootApplication -public class ExampleApplication { - - public static void main(String[] args) { - SpringApplication.run(ExampleApplication.class, args); - } - - @Bean - ForwardedHeaderFilter forwardedHeaderFilter() { - return new ForwardedHeaderFilter(); - } -} diff --git a/src/main/java/trycb/config/CouchbaseConfiguration.java b/src/main/java/trycb/config/CouchbaseConfiguration.java deleted file mode 100644 index f78da6b..0000000 --- a/src/main/java/trycb/config/CouchbaseConfiguration.java +++ /dev/null @@ -1,61 +0,0 @@ -package trycb.config; - -import com.couchbase.client.core.msg.kv.DurabilityLevel; -import com.couchbase.client.java.Bucket; -import com.couchbase.client.java.Cluster; -import com.couchbase.client.java.manager.bucket.BucketSettings; -import com.couchbase.client.java.manager.bucket.BucketType; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.couchbase.config.AbstractCouchbaseConfiguration; - -@Configuration -public class CouchbaseConfiguration extends AbstractCouchbaseConfiguration { - public static final String PROFILE_COLLECTION = "profile"; - - @Override - public String getConnectionString() { - // To connect to capella: - // - with ssl certificate validation: - // return "couchbases://cb.jnym5s9gv4ealbe.cloud.couchbase.com" - // - without ssl validation: - // return "couchbases://cb.jnym5s9gv4ealbe.cloud.couchbase.com?tls=no_verify" - // (replace cb.jnym5s9gv4ealbe.cloud.couchbase.com with your Capella cluster address) - // - return "couchbase://127.0.0.1"; - } - - @Override - public String getUserName() { - return "Administrator"; - } - - @Override - public String getPassword() { - return "password"; - } - - @Override - public String getBucketName() { - return "springdata_quickstart"; - } - - @Bean - public Bucket getCouchbaseBucket(Cluster cluster) throws Exception { - // verify that bucket exists - if (!cluster.buckets().getAllBuckets().containsKey(getBucketName())) { - // create the bucket if it doesn't - cluster.buckets().createBucket( - BucketSettings.create(getBucketName()) - .bucketType(BucketType.COUCHBASE) - .minimumDurabilityLevel(DurabilityLevel.NONE) - .ramQuotaMB(128) - ); - Thread.sleep(1000); - } - - return cluster.bucket(getBucketName()); - } -} - diff --git a/src/main/java/trycb/config/DbSetupRunner.java b/src/main/java/trycb/config/DbSetupRunner.java deleted file mode 100644 index c87d94d..0000000 --- a/src/main/java/trycb/config/DbSetupRunner.java +++ /dev/null @@ -1,80 +0,0 @@ -package trycb.config; - -import com.couchbase.client.core.error.CollectionExistsException; -import com.couchbase.client.core.error.IndexExistsException; -import com.couchbase.client.java.Bucket; -import com.couchbase.client.java.Cluster; -import com.couchbase.client.java.manager.collection.CollectionManager; -import com.couchbase.client.java.manager.collection.CollectionSpec; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.CommandLineRunner; -import org.springframework.stereotype.Component; - -@Component -public final class DbSetupRunner implements CommandLineRunner { - - private static final Logger LOGGER = LoggerFactory.getLogger(DbSetupRunner.class); - - @Autowired - private Bucket bucket; - @Autowired - private Cluster cluster; - @Autowired - private CouchbaseConfiguration config; - - @Override - public void run(String... args) { - try { - // We must create primary index on our bucket in order to query it - cluster.queryIndexes().createPrimaryIndex(config.getBucketName()); - LOGGER.info("Created primary index {}", config.getBucketName()); - } catch (IndexExistsException iee) { - LOGGER.info("Primary index {} already exists", config.getBucketName()); - } catch (Exception e) { - LOGGER.error("Failed to create primary index {}", config.getBucketName(), e); - System.exit(1); - } - - CollectionManager collectionManager = bucket.collections(); - try { - // Making sure that profile collection exists - CollectionSpec colspec = CollectionSpec.create(CouchbaseConfiguration.PROFILE_COLLECTION, "_default"); - collectionManager.createCollection(colspec); - LOGGER.info("Created collection {}", CouchbaseConfiguration.PROFILE_COLLECTION); - } catch (CollectionExistsException e) { - LOGGER.info("Collection {} already exists", CouchbaseConfiguration.PROFILE_COLLECTION); - } catch (Exception e) { - LOGGER.error("Failed to create collection {}", CouchbaseConfiguration.PROFILE_COLLECTION, e); - System.exit(1); - } - - try { - // primary index for querying profiles by id - final String query = "CREATE PRIMARY INDEX default_profile_index ON " + config.getBucketName() + "._default." + CouchbaseConfiguration.PROFILE_COLLECTION; - LOGGER.info("Creating default_profile_index: {}", query); - cluster.query(query); - Thread.sleep(1000); - LOGGER.info("Created primary index on collection {}", CouchbaseConfiguration.PROFILE_COLLECTION); - } catch (IndexExistsException e) { - LOGGER.info("Primary index exists on collection {}", CouchbaseConfiguration.PROFILE_COLLECTION); - } catch (Exception e) { - LOGGER.error("Failed to create primary index on collection {}", CouchbaseConfiguration.PROFILE_COLLECTION, e); - } - - try { - // secondary index for querying profiles by fields - final String query = "CREATE INDEX secondary_profile_index ON " + config.getBucketName() + "._default." + CouchbaseConfiguration.PROFILE_COLLECTION + "(firstName, lastName, address)"; - LOGGER.info("Creating secondary_profile_index: {}", query); - cluster.query(query); - Thread.sleep(1000); - LOGGER.info("Created secondary index on collection {}", CouchbaseConfiguration.PROFILE_COLLECTION); - } catch (IndexExistsException e) { - LOGGER.info("Secondary index exists on collection {}", CouchbaseConfiguration.PROFILE_COLLECTION); - } catch (Exception e) { - LOGGER.error("Failed to create secondary index on collection {}", CouchbaseConfiguration.PROFILE_COLLECTION, e); - } - } -} diff --git a/src/main/java/trycb/controller/IndexController.java b/src/main/java/trycb/controller/IndexController.java deleted file mode 100644 index ce46bdd..0000000 --- a/src/main/java/trycb/controller/IndexController.java +++ /dev/null @@ -1,19 +0,0 @@ -package trycb.controller; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class IndexController { - @GetMapping("/") - public ResponseEntity index() { - // Redirecting to swagger-ui home page - HttpHeaders headers = new HttpHeaders(); - headers.add("Location", "/swagger-ui/index.html"); - return new ResponseEntity(headers, HttpStatus.FOUND); - } -} - diff --git a/src/main/java/trycb/controller/ProfileController.java b/src/main/java/trycb/controller/ProfileController.java deleted file mode 100644 index ae0ea96..0000000 --- a/src/main/java/trycb/controller/ProfileController.java +++ /dev/null @@ -1,86 +0,0 @@ -package trycb.controller; - -import java.util.List; -import java.util.UUID; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.dao.DataRetrievalFailureException; -import org.springframework.data.domain.PageRequest; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import trycb.model.Profile; -import trycb.repository.ProfileRepository; - -@RestController -public class ProfileController { - private static final Logger LOGGER = LoggerFactory.getLogger(ProfileController.class); - @Autowired - private ProfileRepository profileRepository; - - @GetMapping("/profile") - public ResponseEntity> listProfiles( - @RequestParam(required = false) String query, - @RequestParam(defaultValue = "10") int pageSize, - @RequestParam(defaultValue = "0") int page - ) { - if (pageSize < 1 || pageSize > 10) pageSize = 10; - List result; - - if (query == null || query.length() != 0) { - // Couchbase repoitories support request pagination via PageRequest - PageRequest pageRequest = PageRequest.of(page, pageSize); - result = profileRepository.findAll(pageRequest).toList(); - } else { - // This is just a LIKE query. - // For full-text search documentation refer to: - // https://docs.couchbase.com/java-sdk/current/howtos/full-text-searching-with-sdk.html - result = profileRepository.findByText(query, page, pageSize); - } - - if (result != null && result.size() > 0) { - return ResponseEntity.ok(result); - } - - return ResponseEntity.noContent().build(); - } - - @GetMapping("/profile/{id}") - public ResponseEntity getProfileById(@PathVariable("id") UUID id) { - if (id == null) { - return ResponseEntity.status(400).build(); - } - Profile result = profileRepository.findById(id).orElse(null); - if (result == null) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(result); - } - - @PostMapping("/profile") - public ResponseEntity saveProfile(@RequestBody Profile profile) { - // the same endpoint can be used to create and save the object - profile = profileRepository.save(profile); - return ResponseEntity.status(HttpStatus.CREATED).body(profile); - } - - @DeleteMapping("/profile/{id}") - public ResponseEntity deleteProfile(@PathVariable UUID id) { - try { - profileRepository.deleteById(id); - return ResponseEntity.noContent().build(); - } catch (DataRetrievalFailureException e) { - LOGGER.error("Document not found", e); - return ResponseEntity.notFound().build(); - } - } -} diff --git a/src/main/java/trycb/model/Profile.java b/src/main/java/trycb/model/Profile.java deleted file mode 100644 index 232ff57..0000000 --- a/src/main/java/trycb/model/Profile.java +++ /dev/null @@ -1,59 +0,0 @@ -package trycb.model; - -import java.util.UUID; - -import org.springframework.data.annotation.Id; -import org.springframework.data.couchbase.core.mapping.id.GeneratedValue; -import org.springframework.data.couchbase.repository.Collection; -import org.springframework.data.couchbase.repository.Scope; - -@Scope("_default") -@Collection("profile") -public class Profile { - @Id - @GeneratedValue - private UUID id; - private String firstName, lastName; - private Byte age; - private String address; - - public void setId(UUID id) { - this.id = id; - } - - public UUID getId() { - return id; - } - - public void setFirstName(String name) { - this.firstName = name; - } - - public String getFirstName() { - return firstName; - } - - public void setLastName(String lastName) { - this.lastName = lastName; - } - - public String getLastName() { - return lastName; - } - - public void setAge(byte age) { - this.age = age; - } - - public byte getAge() { - return age; - } - - public void setAddress(String address) { - this.address = address; - } - - public String getAddress() { - return address; - } -} diff --git a/src/main/java/trycb/repository/ProfileRepository.java b/src/main/java/trycb/repository/ProfileRepository.java deleted file mode 100644 index b897d4c..0000000 --- a/src/main/java/trycb/repository/ProfileRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package trycb.repository; - -import java.util.List; -import java.util.UUID; - -import org.springframework.data.couchbase.repository.Query; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.repository.PagingAndSortingRepository; -import org.springframework.stereotype.Repository; - -import trycb.model.Profile; - -@Repository -public interface ProfileRepository extends PagingAndSortingRepository { - // Repository method that executes a custom SQL++ query - @Query("#{#n1ql.selectEntity} WHERE firstName LIKE '%' || $1 || '%' OR lastName LIKE '%' || $1 || '%' OR address LIKE '%' || $1 || '%' OFFSET $2 * $3 LIMIT $3") - List findByText(String query, int pageNum, int pageSize); - - Page findByAge(byte age, Pageable pageable); -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e82613c..62c63db 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,8 @@ server.use-forward-headers=true server.forward-headers-strategy=framework - +spring.couchbase.bootstrap-hosts=DB_CONN_STR +spring.couchbase.bucket.name=travel-sample +spring.couchbase.bucket.user=DB_USERNAME +spring.couchbase.bucket.password=DB_PASSWORD +spring.couchbase.scope.name=inventory +spring.mvc.pathmatch.matching-strategy=ANT_PATH_MATCHER \ No newline at end of file diff --git a/src/test/java/org/couchbase/quickstart/springdata/controllers/AirlineIntegrationTest.java b/src/test/java/org/couchbase/quickstart/springdata/controllers/AirlineIntegrationTest.java new file mode 100644 index 0000000..985ee67 --- /dev/null +++ b/src/test/java/org/couchbase/quickstart/springdata/controllers/AirlineIntegrationTest.java @@ -0,0 +1,290 @@ +package org.couchbase.quickstart.springdata.controllers; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.couchbase.quickstart.springdata.models.Airline; +import org.couchbase.quickstart.springdata.models.RestResponsePage; +import org.couchbase.quickstart.springdata.services.AirlineService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.ResourceAccessException; + +import com.couchbase.client.core.error.DocumentNotFoundException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AirlineIntegrationTest { + + @Value("${local.server.port}") + private int port; + + @Value("#{systemEnvironment['DB_CONN_STR'] ?: '${spring.couchbase.bootstrap-hosts:localhost}'}") + private String bootstrapHosts; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private AirlineService airlineService; + + private void deleteAirline(String baseUri, String airlineId) { + try { + if (airlineService.getAirlineById(airlineId).isPresent()) { + restTemplate.delete(baseUri + "/api/v1/airline/" + airlineId); + } + } catch (DocumentNotFoundException | DataRetrievalFailureException | ResourceAccessException e) { + log.warn("Document " + airlineId + " not present prior to test"); + } catch (Exception e) { + log.error("Error deleting test data", e.getMessage()); + } + } + + private void deleteTestAirlineData(String baseUri) { + deleteAirline(baseUri, "airline_create"); + deleteAirline(baseUri, "airline_update"); + deleteAirline(baseUri, "airline_delete"); + } + + private String getBaseUri() { + String baseUri = ""; + if (bootstrapHosts.contains("localhost")) { + baseUri = "http://localhost:" + port; + } else { + baseUri = bootstrapHosts; + } + return baseUri; + } + + @BeforeEach + void setUp() { + String baseUri = getBaseUri(); + deleteTestAirlineData(baseUri); + } + + @AfterEach + void tearDown() { + String baseUri = getBaseUri(); + deleteTestAirlineData(baseUri); + } + + @Test + void testGetAirline() { + ResponseEntity response = restTemplate + .getForEntity("/api/v1/airline/airline_10", Airline.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Airline airline = response.getBody(); + assert airline != null; + + Airline expectedAirline = Airline.builder() + .id("airline_10") + .type("airline") + .name("40-Mile Air") + .iata("Q5") + .icao("MLA") + .callsign("MILE-AIR") + .country("United States") + .build(); + assertThat(airline).isEqualTo(expectedAirline); + } + + @Test + void testCreateAirline() { + Airline airline = Airline.builder() + .id("airline_create") + .type("airline") + .name("Test Airline") + .iata("TA") + .icao("TST") + .callsign("TEST") + .country("United States") + .build(); + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/airline/" + airline.getId(), airline, + Airline.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + Airline createdAirline = response.getBody(); + + assert createdAirline != null; + assertThat(createdAirline).isEqualTo(airline); + } + + @Test + void testUpdateAirline() { + Airline airline = Airline.builder() + .id("airline_update") + .type("airline") + .name("Updated Test Airline") + .iata("TA") + .icao("TST") + .callsign("TEST") + .country("United States") + .build(); + restTemplate.postForEntity("/api/v1/airline/" + airline.getId(), airline, + Airline.class); + restTemplate.put("/api/v1/airline/" + airline.getId(), airline); + ResponseEntity response = restTemplate + .getForEntity("/api/v1/airline/" + airline.getId(), + Airline.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Airline updatedAirline = response.getBody(); + assertThat(updatedAirline) + .isNotNull() + .isEqualTo(airline); + } + + @Test + void testDeleteAirline() { + String airlineIdToDelete = "airline_delete"; + Airline airline = Airline.builder() + .id(airlineIdToDelete) + .type("airline") + .name("Test Airline") + .iata("TA") + .icao("TST") + .callsign("TEST") + .country("United States") + .build(); + restTemplate.postForEntity("/api/v1/airline/" + airline.getId(), airline, + Airline.class); + restTemplate.delete("/api/v1/airline/" + airlineIdToDelete); + ResponseEntity response = restTemplate + .getForEntity("/api/v1/airline/" + airlineIdToDelete, + Airline.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void testListAirlines() { + ResponseEntity> response = restTemplate.exchange( + "/api/v1/airline/list", HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + RestResponsePage airlines = response.getBody(); + assertThat(airlines).isNotNull(); + assertThat(airlines.getSize()).isEqualTo(10); + } + + @Test + void testListAirlinesByCountry() { + + String country = "United States"; + ResponseEntity> response = restTemplate.exchange( + "/api/v1/airline/list?country=" + country, + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + RestResponsePage airlines = response.getBody(); + assert airlines != null; + + Airline airline = airlines.stream().filter(a -> a.getId().equals("airline_10226")).findFirst() + .orElse(null); + assertThat(airline).isNotNull(); + + Airline expectedAirline = Airline.builder() + .id("airline_10226") + .type("airline") + .name("Atifly") + .iata("A1") + .icao("A1F") + .callsign("atifly") + .country("United States") + .build(); + assertThat(airline).isEqualTo(expectedAirline); + + country = "France"; + ResponseEntity> response2 = restTemplate.exchange( + "/api/v1/airline/list?country=" + country, + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }); + + assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK); + + RestResponsePage airlines2 = response2.getBody(); + assert airlines2 != null; + Airline airline2 = airlines2.stream().filter(a -> a.getId().equals("airline_1191")).findFirst() + .orElse(null); + + Airline expectedAirline2 = Airline.builder() + .id("airline_1191") + .type("airline") + .name("Air Austral") + .iata("UU") + .icao("REU") + .callsign("REUNION") + .country("France") + .build(); + assertThat(airline2).isEqualTo(expectedAirline2); + + } + + @Test + void testListAirlinesByDestinationAirport() { + String airport = "LAX"; + ResponseEntity> response = restTemplate.exchange( + "/api/v1/airline/to-airport?destinationAirport=" + airport, + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + RestResponsePage airlines = response.getBody(); + assert airlines != null; + Airline airline = airlines.stream().filter(a -> a.getId().equals("airline_3029")).findFirst() + .orElse(null); + assertThat(airline).isNotNull(); + + Airline expectedAirline = Airline.builder() + .id("airline_3029") + .type("airline") + .name("JetBlue Airways") + .iata("B6") + .icao("JBU") + .callsign("JETBLUE") + .country("United States") + .build(); + assertThat(airline).isEqualTo(expectedAirline); + + airport = "CDG"; + ResponseEntity> response2 = restTemplate.exchange( + "/api/v1/airline/to-airport?destinationAirport=" + airport, + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }); + + assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.OK); + RestResponsePage airlines2 = response2.getBody(); + + assert airlines2 != null; + + Airline airline2 = airlines2.stream().filter(a -> a.getId().equals("airline_137")).findFirst() + .orElse(null); + + Airline expectedAirline2 = Airline.builder() + .id("airline_137") + .type("airline") + .name("Air France") + .iata("AF") + .icao("AFR") + .callsign("AIRFRANS") + .country("France") + .build(); + + assertThat(airline2).isEqualTo(expectedAirline2); + + } +} diff --git a/src/test/java/org/couchbase/quickstart/springdata/controllers/AirportIntegrationTest.java b/src/test/java/org/couchbase/quickstart/springdata/controllers/AirportIntegrationTest.java new file mode 100644 index 0000000..901c739 --- /dev/null +++ b/src/test/java/org/couchbase/quickstart/springdata/controllers/AirportIntegrationTest.java @@ -0,0 +1,198 @@ +package org.couchbase.quickstart.springdata.controllers; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.couchbase.quickstart.springdata.models.Airport; +import org.couchbase.quickstart.springdata.models.Airport.Geo; +import org.couchbase.quickstart.springdata.models.RestResponsePage; +import org.couchbase.quickstart.springdata.services.AirportService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.ResourceAccessException; + +import com.couchbase.client.core.error.DocumentNotFoundException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AirportIntegrationTest { + + @Value("${local.server.port}") + private int port; + + @Value("#{systemEnvironment['DB_CONN_STR'] ?: '${spring.couchbase.bootstrap-hosts:localhost}'}") + private String bootstrapHosts; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private AirportService airportService; + + private void deleteAirport(String baseUri, String airportId) { + try { + if (airportService.getAirportById(airportId).isPresent()) { + restTemplate.delete(baseUri + "/api/v1/airport/" + airportId); + } + } catch (DocumentNotFoundException | DataRetrievalFailureException | ResourceAccessException e) { + log.warn("Document " + airportId + " not present prior to test"); + } catch (Exception e) { + log.error("Error deleting test data", e.getMessage()); + } + } + + private void deleteTestAirportData(String baseUri) { + deleteAirport(baseUri, "airport_create"); + deleteAirport(baseUri, "airport_update"); + deleteAirport(baseUri, "airport_delete"); + } + + private String getBaseUri() { + String baseUri = ""; + if (bootstrapHosts.contains("localhost")) { + baseUri = "http://localhost:" + port; + } else { + baseUri = bootstrapHosts; + } + return baseUri; + } + + @BeforeEach + void setUp() { + String baseUri = getBaseUri(); + deleteTestAirportData(baseUri); + } + + @AfterEach + void tearDown() { + String baseUri = getBaseUri(); + deleteTestAirportData(baseUri); + } + + @Test + void testGetAirport() { + ResponseEntity response = restTemplate + .getForEntity("/api/v1/airport/airport_1255", + Airport.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Airport airport = response.getBody(); + assert airport != null; + Airport expectedAirport = Airport.builder() + .id("airport_1255") + .type("airport") + .airportName("Peronne St Quentin") + .city("Peronne") + .country("France") + .faa(null) + .icao("LFAG").tz("Europe/Paris") + .geo(Geo.builder() + .lat(49.868547) + .lon(3.029578) + .alt(295.0) + .build()) + .build(); + assertThat(airport).isEqualTo(expectedAirport); + } + + @Test + void testCreateAirport() { + Airport airport = Airport.builder().id("airport_create").type("airport").airportName("Test Airport") + .city("Test City").country("Test Country").faa("TST").icao("TEST") + .tz("Test Timezone").geo(new Geo(1.0, 2.0, 3.0)).build(); + ResponseEntity response = restTemplate.postForEntity( + "/api/v1/airport/" + airport.getId(), airport, + Airport.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + Airport createdAirport = response.getBody(); + assert createdAirport != null; + assertThat(createdAirport).isEqualTo(airport); + } + + @Test + void testUpdateAirport() { + Airport airport = Airport.builder().id("airport_update").type("airport") + .airportName("Updated Test Airport").city("Updated Test City") + .country("Updated Test Country").faa("TST").icao("TEST") + .tz("Updated Test Timezone").geo(new Geo(1.0, 2.0, 3.0)).build(); + restTemplate.postForEntity("/api/v1/airport/" + airport.getId(), airport, + Airport.class); + restTemplate.put("/api/v1/airport/" + airport.getId(), airport); + ResponseEntity response = restTemplate + .getForEntity("/api/v1/airport/" + airport.getId(), + Airport.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Airport updatedAirport = response.getBody(); + assert updatedAirport != null; + assertThat(updatedAirport).isEqualTo(airport); + } + + @Test + void testDeleteAirport() { + Airport airport = Airport.builder().id("airport_delete").type("airport").airportName("Test Airport") + .city("Test City").country("Test Country").faa("TST").icao("TEST") + .tz("Test Timezone").geo(new Geo(1.0, 2.0, 3.0)).build(); + restTemplate.postForEntity("/api/v1/airport/" + airport.getId(), airport, + Airport.class); + restTemplate.delete("/api/v1/airport/" + airport.getId()); + ResponseEntity response = restTemplate + .getForEntity("/api/v1/airport/" + airport.getId(), + Airport.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void testListAirports() { + ResponseEntity> response = restTemplate.exchange( + "/api/v1/airport/list", HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + RestResponsePage airports = response.getBody(); + assertThat(airports).isNotNull(); + assertThat(airports).hasSize(10); + } + + @Test + void testListDirectConnections() { + String airportCode = "LAX"; + ResponseEntity> response = restTemplate.exchange( + "/api/v1/airport/direct-connections?airportCode=" + airportCode + "&page=0&size=10", + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + RestResponsePage directConnections = response.getBody(); + + assertThat(directConnections).isNotNull().hasSize(10); + assertThat(directConnections).contains("NRT", "CUN", "GDL", "HMO", "MEX", "MZT", "PVR", "SJD", "ZIH", + "ZLO"); + + airportCode = "JFK"; + response = restTemplate.exchange( + "/api/v1/airport/direct-connections?airportCode=" + airportCode + "&page=0&size=10", + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + directConnections = response.getBody(); + + assertThat(directConnections).isNotNull().hasSize(10); + assertThat(directConnections).contains("DEL", "LHR", "EZE", "ATL", "CUN", "MEX", "EZE", "LAX", "SAN", + "SEA"); + + } +} diff --git a/src/test/java/org/couchbase/quickstart/springdata/controllers/RouteIntegrationTest.java b/src/test/java/org/couchbase/quickstart/springdata/controllers/RouteIntegrationTest.java new file mode 100644 index 0000000..a5cf34c --- /dev/null +++ b/src/test/java/org/couchbase/quickstart/springdata/controllers/RouteIntegrationTest.java @@ -0,0 +1,289 @@ +package org.couchbase.quickstart.springdata.controllers; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; + +import org.couchbase.quickstart.springdata.models.RestResponsePage; +import org.couchbase.quickstart.springdata.models.Route; +import org.couchbase.quickstart.springdata.services.RouteService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.dao.DataRetrievalFailureException; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.ResourceAccessException; + +import com.couchbase.client.core.error.DocumentNotFoundException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RouteIntegrationTest { + + @Value("${local.server.port}") + private int port; + + @Value("#{systemEnvironment['DB_CONN_STR'] ?: '${spring.couchbase.bootstrap-hosts:localhost}'}") + private String bootstrapHosts; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private RouteService routeService; + + private void deleteRoute(String baseUri, String routeId) { + try { + if (routeService.getRouteById(routeId).isPresent()) { + restTemplate.delete(baseUri + "/api/v1/route/" + routeId); + } + } catch (DocumentNotFoundException | DataRetrievalFailureException | ResourceAccessException e) { + log.warn("Document " + routeId + " not present prior to test"); + } catch (Exception e) { + log.error("Error deleting test data", e.getMessage()); + } + } + + private void deleteTestRouteData(String baseUri) { + deleteRoute(baseUri, "route_create"); + deleteRoute(baseUri, "route_update"); + deleteRoute(baseUri, "route_delete"); + } + + private String getBaseUri() { + String baseUri = ""; + if (bootstrapHosts.contains("localhost")) { + baseUri = "http://localhost:" + port; + } else { + baseUri = bootstrapHosts; + } + return baseUri; + } + + @BeforeEach + void setUp() { + String baseUri = getBaseUri(); + deleteTestRouteData(baseUri); + } + + @AfterEach + void tearDown() { + String baseUri = getBaseUri(); + deleteTestRouteData(baseUri); + } + + @Test + void testGetRoute() throws Exception { + ResponseEntity response = restTemplate + .getForEntity("/api/v1/route/route_10001", Route.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Route route = response.getBody(); + assert route != null; + Route expectedRoute = Route.builder() + .id("route_10001") + .type("route") + .airline("AF") + .airlineId("airline_137") + .sourceAirport("TLV") + .destinationAirport("NCE") + .stops(0) + .equipment("320") + .schedule(Arrays.asList( + new Route.Schedule(0, "AF248", "21:24:00"), + new Route.Schedule(1, "AF517", "13:36:00"), + new Route.Schedule(1, "AF279", "21:35:00"), + new Route.Schedule(1, "AF753", "00:54:00"), + new Route.Schedule(1, "AF079", "15:29:00"), + new Route.Schedule(1, "AF756", "06:16:00"), + new Route.Schedule(2, "AF499", "03:39:00"), + new Route.Schedule(2, "AF158", "08:49:00"), + new Route.Schedule(2, "AF337", "06:01:00"), + new Route.Schedule(2, "AF436", "11:48:00"), + new Route.Schedule(2, "AF660", "09:35:00"), + new Route.Schedule(3, "AF692", "12:55:00"), + new Route.Schedule(3, "AF815", "19:38:00"), + new Route.Schedule(3, "AF455", "12:33:00"), + new Route.Schedule(3, "AF926", "19:45:00"), + new Route.Schedule(4, "AF133", "10:36:00"), + new Route.Schedule(4, "AF999", "07:46:00"), + new Route.Schedule(4, "AF703", "15:42:00"), + new Route.Schedule(5, "AF656", "05:40:00"), + new Route.Schedule(6, "AF185", "16:21:00"), + new Route.Schedule(6, "AF110", "00:56:00"), + new Route.Schedule(6, "AF783", "06:07:00"), + new Route.Schedule(6, "AF108", "04:54:00"), + new Route.Schedule(6, "AF673", "12:07:00"))) + .distance(2735.2013399811754) + .build(); + assertThat(route).isEqualTo(expectedRoute); + } + + @Test + void testCreateRoute() throws Exception { + + Route route = Route.builder() + .id("route_create") + .type("route") + .airline("AF") + .airlineId("airline_137") + .sourceAirport("TLV") + .destinationAirport("MRS") + .stops(0) + .equipment("320") + .schedule(Arrays.asList( + new Route.Schedule(0, "AF198", "10:13:00"), + new Route.Schedule(0, "AF547", "19:14:00"), + new Route.Schedule(0, "AF943", "01:31:00"), + new Route.Schedule(1, "AF356", "12:40:00"), + new Route.Schedule(1, "AF480", "08:58:00"), + new Route.Schedule(1, "AF250", "12:59:00"), + new Route.Schedule(1, "AF130", "04:45:00"), + new Route.Schedule(2, "AF997", "00:31:00"), + new Route.Schedule(2, "AF223", "19:41:00"), + new Route.Schedule(2, "AF890", "15:14:00"), + new Route.Schedule(2, "AF399", "00:30:00"), + new Route.Schedule(2, "AF328", "16:18:00"), + new Route.Schedule(3, "AF074", "23:50:00"), + new Route.Schedule(3, "AF556", "11:33:00"), + new Route.Schedule(4, "AF064", "13:23:00"), + new Route.Schedule(4, "AF596", "12:09:00"), + new Route.Schedule(4, "AF818", "08:02:00"), + new Route.Schedule(5, "AF967", "11:33:00"), + new Route.Schedule(5, "AF730", "19:42:00"), + new Route.Schedule(6, "AF882", "17:07:00"), + new Route.Schedule(6, "AF485", "17:03:00"), + new Route.Schedule(6, "AF898", "10:01:00"), + new Route.Schedule(6, "AF496", "07:00:00"))) + .distance(2881.617376098415) + .build(); + ResponseEntity response = restTemplate + .postForEntity("/api/v1/route/" + route.getId(), route, + Route.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + Route createdRoute = response.getBody(); + assert createdRoute != null; + assertThat(createdRoute).isEqualTo(route); + } + + @Test + void testUpdateRoute() throws Exception { + + Route route = Route.builder() + .id("route_update") + .type("route") + .airline("AF") + .airlineId("airline_137") + .sourceAirport("TLV") + .destinationAirport("MRS") + .stops(0) + .equipment("320") + .schedule(Arrays.asList( + new Route.Schedule(0, "AF198", "10:13:00"), + new Route.Schedule(0, "AF547", "19:14:00"), + new Route.Schedule(0, "AF943", "01:31:00"), + new Route.Schedule(1, "AF356", "12:40:00"), + new Route.Schedule(1, "AF480", "08:58:00"), + new Route.Schedule(1, "AF250", "12:59:00"), + new Route.Schedule(1, "AF130", "04:45:00"), + new Route.Schedule(2, "AF997", "00:31:00"), + new Route.Schedule(2, "AF223", "19:41:00"), + new Route.Schedule(2, "AF890", "15:14:00"), + new Route.Schedule(2, "AF399", "00:30:00"), + new Route.Schedule(2, "AF328", "16:18:00"), + new Route.Schedule(3, "AF074", "23:50:00"), + new Route.Schedule(3, "AF556", "11:33:00"), + new Route.Schedule(4, "AF064", "13:23:00"), + new Route.Schedule(4, "AF596", "12:09:00"), + new Route.Schedule(4, "AF818", "08:02:00"), + new Route.Schedule(5, "AF967", "11:33:00"), + new Route.Schedule(5, "AF730", "19:42:00"), + new Route.Schedule(6, "AF882", "17:07:00"), + new Route.Schedule(6, "AF485", "17:03:00"), + new Route.Schedule(6, "AF898", "10:01:00"), + new Route.Schedule(6, "AF496", "07:00:00"))) + .distance(2881.617376098415) + .build(); + + restTemplate.postForEntity("/api/v1/route/" + route.getId(), route, + Route.class); + restTemplate.put("/api/v1/route/" + route.getId(), route); + ResponseEntity response = restTemplate + .getForEntity("/api/v1/route/" + route.getId(), + Route.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Route updatedRoute = response.getBody(); + assert updatedRoute != null; + assertThat(updatedRoute).isEqualTo(route); + } + + @Test + void testDeleteRoute() throws Exception { + + Route route = Route.builder() + .id("route_delete") + .type("route") + .airline("AF") + .airlineId("airline_137") + .sourceAirport("TLV") + .destinationAirport("MRS") + .stops(0) + .equipment("320") + .schedule(Arrays.asList( + new Route.Schedule(0, "AF198", "10:13:00"), + new Route.Schedule(0, "AF547", "19:14:00"), + new Route.Schedule(0, "AF943", "01:31:00"), + new Route.Schedule(1, "AF356", "12:40:00"), + new Route.Schedule(1, "AF480", "08:58:00"), + new Route.Schedule(1, "AF250", "12:59:00"), + new Route.Schedule(1, "AF130", "04:45:00"), + new Route.Schedule(2, "AF997", "00:31:00"), + new Route.Schedule(2, "AF223", "19:41:00"), + new Route.Schedule(2, "AF890", "15:14:00"), + new Route.Schedule(2, "AF399", "00:30:00"), + new Route.Schedule(2, "AF328", "16:18:00"), + new Route.Schedule(3, "AF074", "23:50:00"), + new Route.Schedule(3, "AF556", "11:33:00"), + new Route.Schedule(4, "AF064", "13:23:00"), + new Route.Schedule(4, "AF596", "12:09:00"), + new Route.Schedule(4, "AF818", "08:02:00"), + new Route.Schedule(5, "AF967", "11:33:00"), + new Route.Schedule(5, "AF730", "19:42:00"), + new Route.Schedule(6, "AF882", "17:07:00"), + new Route.Schedule(6, "AF485", "17:03:00"), + new Route.Schedule(6, "AF898", "10:01:00"), + new Route.Schedule(6, "AF496", "07:00:00"))) + .distance(2881.617376098415) + .build(); + restTemplate.postForEntity("/api/v1/route/" + route.getId(), route, + Route.class); + restTemplate.delete("/api/v1/route/" + route.getId()); + ResponseEntity response = restTemplate + .getForEntity("/api/v1/route/" + route.getId(), + Route.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void testListRoutes() { + ResponseEntity> response = restTemplate.exchange( + "/api/v1/route/list", + HttpMethod.GET, null, new ParameterizedTypeReference>() { + }); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + RestResponsePage routes = response.getBody(); + assert routes != null; + assertThat(routes).hasSize(10); + } + +} \ No newline at end of file diff --git a/startcb.sh b/startcb.sh deleted file mode 100755 index 82ba092..0000000 --- a/startcb.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -OS_USER_GROUP="${OS_USER_GROUP:=gitpod:gitpod}" - -CB_USER="${CB_USER:-Administrator}" -CB_PSWD="${CB_PSWD:-password}" -CB_HOST="${CB_HOST:-127.0.0.1}" -CB_PORT="${CB_PORT:-8091}" -CB_NAME="${CB_NAME:-cbgitpod}" -CB_BCKT="${CB_BCKT:-springdata_quickstart}" -CB_COLL="${CB_COLL:-_default.profile}" - -CB_SERVICES="${CB_SERVICES:-data,query,index,fts,eventing,analytics}" - -CB_KV_RAMSIZE="${CB_KV_RAMSIZE:-1024}" -CB_INDEX_RAMSIZE="${CB_INDEX_RAMSIZE:-256}" -CB_FTS_RAMSIZE="${CB_FTS_RAMSIZE:-256}" -CB_EVENTING_RAMSIZE="${CB_EVENTING_RAMSIZE:-512}" -CB_ANALYTICS_RAMSIZE="${CB_ANALYTICS_RAMSIZE:-1024}" - -set -euo pipefail - -COUCHBASE_TOP=/opt/couchbase -sudo chown -R ${OS_USER_GROUP} ${COUCHBASE_TOP}/var - -echo "Start couchbase..." -couchbase-server --start - -echo "Waiting for couchbase-server..." -until curl -s http://${CB_HOST}:${CB_PORT}/pools > /dev/null; do - sleep 5 - echo "Waiting for couchbase-server..." -done - -echo "Waiting for couchbase-server... ready" - -if ! couchbase-cli server-list -c ${CB_HOST}:${CB_PORT} -u ${CB_USER} -p ${CB_PSWD} > /dev/null; then - echo "couchbase cluster-init..." - couchbase-cli cluster-init \ - --services ${CB_SERVICES} \ - --cluster-name ${CB_NAME} \ - --cluster-username ${CB_USER} \ - --cluster-password ${CB_PSWD} \ - --cluster-ramsize ${CB_KV_RAMSIZE} \ - --cluster-index-ramsize ${CB_INDEX_RAMSIZE} \ - --cluster-fts-ramsize ${CB_FTS_RAMSIZE} \ - --cluster-eventing-ramsize ${CB_EVENTING_RAMSIZE} \ - --cluster-analytics-ramsize ${CB_ANALYTICS_RAMSIZE} -fi - -sleep 3 - -couchbase-cli bucket-create -c "${CB_HOST}:${CB_PORT}" -u "${CB_USER}" -p "${CB_PSWD}" --bucket "${CB_BCKT}" --bucket-type couchbase --bucket-ramsize "$CB_KV_RAMSIZE" - -sleep 3 - -couchbase-cli collection-manage -c "${CB_HOST}:${CB_PORT}" -u "${CB_USER}" -p "${CB_PSWD}" --bucket "${CB_BCKT}" --create-collection "${CB_COLL}" - -sleep 3