diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..9b50a10 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,31 @@ +# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Java CI with Maven + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Build with Maven + run: mvn -B package --file pom.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a32aab2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store +/.idea diff --git a/GraphQL collection.postman_collection.json b/GraphQL collection.postman_collection.json new file mode 100644 index 0000000..8f45c34 --- /dev/null +++ b/GraphQL collection.postman_collection.json @@ -0,0 +1,124 @@ +{ + "info": { + "_postman_id": "bae0e3d0-2b86-46aa-b6df-523497a2296a", + "name": "GraphQL collection", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "mutations", + "item": [ + { + "name": "createPost", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "mutation createPost ($title: String!, $text: String!, $category: String, $authorId: String!) {\n createPost (title: $title, text: $text, category: $category, authorId: $authorId) {\n id\n title\n text\n category\n }\n}", + "variables": "{\n \"title\": \"new post\",\n \"text\": \"new post text\",\n \"category\": \"category\",\n \"authorId\": \"Author0\"\n}" + } + }, + "url": { + "raw": "http://localhost:8080/graphql", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "graphql" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "queries", + "item": [ + { + "name": "get recent posts", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "{\r\n recentPosts(count: 10, offset: 0) {\r\n id\r\n title\r\n category\r\n text\r\n author {\r\n id\r\n name\r\n thumbnail\r\n }\r\n }\r\n}", + "variables": "" + } + }, + "url": { + "raw": "http://localhost:8080/graphql", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "graphql" + ] + } + }, + "response": [] + }, + { + "name": "recentPosts - variables", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "query recentPosts ($count: Int, $offset: Int) {\n recentPosts (count: $count, offset: $offset) {\n id\n title\n text\n category\n }\n}", + "variables": "{\n \"count\": 5,\n \"offset\": 0\n}" + } + }, + "url": { + "raw": "http://localhost:8080/graphql", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "graphql" + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "url", + "value": "", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 557c89f..f49ff69 100644 --- a/README.md +++ b/README.md @@ -1 +1,43 @@ -# graphql-box \ No newline at end of file +# graphql-box + +## Technologies: +JDK corretto-17.0.9, Spring Boot 2, GraphQL, Maven 3 + +## Build & Run Tests: +``` +mvn clean package +``` + +## Run Main Class Directly: +``` +com.graphql.box.intro.GraphqlApplication +``` + +### GraphQL Queries & Mutation (Methods: Postman, Curl, & GraphiQL UI) + +#### Query + +##### curl +```shell script +curl \ +--request POST 'localhost:8080/graphql' \ +--header 'Content-Type: application/json' \ +--data-raw '{"query":"query {\n recentPosts(count: 2, offset: 0) {\n id\n title\n author {\n id\n posts {\n id\n }\n }\n }\n}"}' +``` +##### GraphiQL UI +![recentPosts-graphiql](./screenshots/recentPosts-graphiql.png?raw=true "recentPosts-graphiql") + +##### Postman +![recentPosts-postman](./screenshots/recentPosts-postman.png?raw=true "recentPosts-postman") + +#### Mutation + +##### curl +```shell script +curl \ +--request POST 'localhost:8080/graphql' \ +--header 'Content-Type: application/json' \ +--data-raw '{"query":"mutation {\n createPost(title: \"New Title\", authorId: \"Author2\", text: \"New Text\") {\n id\n category\n author {\n id\n name\n }\n }\n}"}' +``` +##### GraphiQL UI +![createPost-graphiql](./screenshots/createPost-graphiql.png?raw=true "recentPosts-graphiql") diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..74bc5e3 --- /dev/null +++ b/pom.xml @@ -0,0 +1,96 @@ + + + 4.0.0 + graphql-sandbox + graphql-sandbox + war + + com.graphql.box + 1.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.11 + + + + + 4.0.1 + 1.5.1 + 1.3.5 + 1.6.2 + 3.3.2 + 1.7.0 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-graphql + + + org.projectlombok + lombok + ${lombok.version} + + + com.h2database + h2 + + + jakarta.annotation + jakarta.annotation-api + ${jakarta.annotation-api.version} + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webflux + test + + + org.springframework.graphql + spring-graphql-test + test + + + org.skyscreamer + jsonassert + ${jsonassert.version} + test + + + javax.servlet + javax.servlet-api + ${servlet.version} + provided + + + + + + + kr.motd.maven + os-maven-plugin + ${os-maven-plugin.version} + + + + + \ No newline at end of file diff --git a/screenshots/createPost-graphiql.png b/screenshots/createPost-graphiql.png new file mode 100644 index 0000000..8d2c9f8 Binary files /dev/null and b/screenshots/createPost-graphiql.png differ diff --git a/screenshots/recentPosts-graphiql.png b/screenshots/recentPosts-graphiql.png new file mode 100644 index 0000000..a9aee29 Binary files /dev/null and b/screenshots/recentPosts-graphiql.png differ diff --git a/screenshots/recentPosts-postman.png b/screenshots/recentPosts-postman.png new file mode 100644 index 0000000..5639900 Binary files /dev/null and b/screenshots/recentPosts-postman.png differ diff --git a/src/main/java/com/graphql/box/intro/GraphqlApplication.java b/src/main/java/com/graphql/box/intro/GraphqlApplication.java new file mode 100644 index 0000000..8058f98 --- /dev/null +++ b/src/main/java/com/graphql/box/intro/GraphqlApplication.java @@ -0,0 +1,16 @@ +package com.graphql.box.intro; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; + +@SpringBootApplication(exclude = { + SecurityAutoConfiguration.class +}) +public class GraphqlApplication { + + public static void main(String[] args) { + SpringApplication.run(GraphqlApplication.class, args); + } + +} diff --git a/src/main/java/com/graphql/box/intro/config/GraphqlConfiguration.java b/src/main/java/com/graphql/box/intro/config/GraphqlConfiguration.java new file mode 100644 index 0000000..83bb77a --- /dev/null +++ b/src/main/java/com/graphql/box/intro/config/GraphqlConfiguration.java @@ -0,0 +1,45 @@ +package com.graphql.box.intro.config; + +import com.graphql.box.intro.dao.AuthorDao; +import com.graphql.box.intro.dto.Post; +import com.graphql.box.intro.dao.PostDao; +import com.graphql.box.intro.dto.Author; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; +import java.util.List; + +@Configuration +public class GraphqlConfiguration { + + @Bean + public PostDao postDao() { + List posts = new ArrayList<>(); + for (int postId = 0; postId < 10; ++postId) { + for (int authorId = 0; authorId < 10; ++authorId) { + Post post = new Post(); + post.setId("Post" + authorId + postId); + post.setTitle("Post " + authorId + ":" + postId); + post.setCategory("Post category"); + post.setText("Post " + postId + " + by author " + authorId); + post.setAuthorId("Author" + authorId); + posts.add(post); + } + } + return new PostDao(posts); + } + + @Bean + public AuthorDao authorDao() { + List authors = new ArrayList<>(); + for (int authorId = 0; authorId < 10; ++authorId) { + Author author = new Author(); + author.setId("Author" + authorId); + author.setName("Author " + authorId); + author.setThumbnail("http://example.com/authors/" + authorId); + authors.add(author); + } + return new AuthorDao(authors); + } +} diff --git a/src/main/java/com/graphql/box/intro/controller/AuthorController.java b/src/main/java/com/graphql/box/intro/controller/AuthorController.java new file mode 100644 index 0000000..85e3a6c --- /dev/null +++ b/src/main/java/com/graphql/box/intro/controller/AuthorController.java @@ -0,0 +1,24 @@ +package com.graphql.box.intro.controller; + +import com.graphql.box.intro.dto.Author; +import com.graphql.box.intro.dto.Post; +import com.graphql.box.intro.dao.PostDao; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.stereotype.Controller; + +import java.util.List; + +@Controller +public class AuthorController { + + private final PostDao postDao; + + public AuthorController(PostDao postDao) { + this.postDao = postDao; + } + + @SchemaMapping + public List posts(Author author) { + return postDao.getAuthorPosts(author.getId()); + } +} diff --git a/src/main/java/com/graphql/box/intro/controller/PostController.java b/src/main/java/com/graphql/box/intro/controller/PostController.java new file mode 100644 index 0000000..12ee4d8 --- /dev/null +++ b/src/main/java/com/graphql/box/intro/controller/PostController.java @@ -0,0 +1,56 @@ +package com.graphql.box.intro.controller; + +import com.graphql.box.intro.dto.Author; +import com.graphql.box.intro.dao.AuthorDao; +import com.graphql.box.intro.dto.Post; +import com.graphql.box.intro.dao.PostDao; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.stereotype.Controller; + +import java.util.List; +import java.util.UUID; + +@Controller +public class PostController { + + private final PostDao postDao; + private final AuthorDao authorDao; + + public PostController(PostDao postDao, AuthorDao authorDao) { + this.postDao = postDao; + this.authorDao = authorDao; + } + + @QueryMapping + public List recentPosts(@Argument int count, @Argument int offset) { + return postDao.getRecentPosts(count, offset); + } + + @SchemaMapping + public Author author(Post post) { + return authorDao.getAuthor(post.getAuthorId()); + } + + @SchemaMapping(typeName="Post", field="first_author") + public Author getFirstAuthor(Post post) { + return authorDao.getAuthor(post.getAuthorId()); + } + + @MutationMapping + public Post createPost(@Argument String title, @Argument String text, + @Argument String category, @Argument String authorId) { + Post post = new Post(); + post.setId(UUID.randomUUID().toString()); + post.setTitle(title); + post.setText(text); + post.setCategory(category); + post.setAuthorId(authorId); + postDao.savePost(post); + + return post; + } + +} diff --git a/src/main/java/com/graphql/box/intro/dao/AuthorDao.java b/src/main/java/com/graphql/box/intro/dao/AuthorDao.java new file mode 100644 index 0000000..31f2d9b --- /dev/null +++ b/src/main/java/com/graphql/box/intro/dao/AuthorDao.java @@ -0,0 +1,20 @@ +package com.graphql.box.intro.dao; + +import com.graphql.box.intro.dto.Author; + +import java.util.List; + +public class AuthorDao { + private final List authors; + + public AuthorDao(List authors) { + this.authors = authors; + } + + public Author getAuthor(String id) { + return authors.stream() + .filter(author -> id.equalsIgnoreCase(author.getId())) + .findFirst() + .orElseThrow(RuntimeException::new); + } +} diff --git a/src/main/java/com/graphql/box/intro/dao/PostDao.java b/src/main/java/com/graphql/box/intro/dao/PostDao.java new file mode 100644 index 0000000..1748dbf --- /dev/null +++ b/src/main/java/com/graphql/box/intro/dao/PostDao.java @@ -0,0 +1,32 @@ +package com.graphql.box.intro.dao; + +import com.graphql.box.intro.dto.Post; + +import java.util.List; +import java.util.stream.Collectors; + +public class PostDao { + + private final List posts; + + public PostDao(List posts) { + this.posts = posts; + } + + public List getRecentPosts(int count, int offset) { + return posts.stream() + .skip(offset) + .limit(count) + .collect(Collectors.toList()); + } + + public List getAuthorPosts(String author) { + return posts.stream() + .filter(post -> author.equals(post.getAuthorId())) + .collect(Collectors.toList()); + } + + public void savePost(Post post) { + posts.add(post); + } +} diff --git a/src/main/java/com/graphql/box/intro/dto/Author.java b/src/main/java/com/graphql/box/intro/dto/Author.java new file mode 100644 index 0000000..4c5918a --- /dev/null +++ b/src/main/java/com/graphql/box/intro/dto/Author.java @@ -0,0 +1,11 @@ +package com.graphql.box.intro.dto; + +import lombok.Data; + +@Data +public class Author { + + private String id; + private String name; + private String thumbnail; +} diff --git a/src/main/java/com/graphql/box/intro/dto/Post.java b/src/main/java/com/graphql/box/intro/dto/Post.java new file mode 100644 index 0000000..4be696e --- /dev/null +++ b/src/main/java/com/graphql/box/intro/dto/Post.java @@ -0,0 +1,14 @@ +package com.graphql.box.intro.dto; + +import lombok.Data; + +@Data +public class Post { + + private String id; + private String title; + private String text; + private String category; + private String authorId; + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..52586ed --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,4 @@ +spring: + graphql: + graphiql: + enabled: true diff --git a/src/main/resources/graphql/post.graphqls b/src/main/resources/graphql/post.graphqls new file mode 100644 index 0000000..49beb44 --- /dev/null +++ b/src/main/resources/graphql/post.graphqls @@ -0,0 +1,25 @@ +type Post { + id: ID! + title: String! + text: String! + category: String + author: Author! + first_author: Author! +} + +type Author { + id: ID! + name: String! + thumbnail: String + posts: [Post]! +} + +# The Root Query for the application +type Query { + recentPosts(count: Int, offset: Int): [Post]! +} + +# The Root Mutation for the application +type Mutation { + createPost(title: String!, text: String!, category: String, authorId: String!) : Post! +} diff --git a/src/test/java/com/graphql/box/intro/PostControllerIntegrationTest.java b/src/test/java/com/graphql/box/intro/PostControllerIntegrationTest.java new file mode 100644 index 0000000..f0d44fb --- /dev/null +++ b/src/test/java/com/graphql/box/intro/PostControllerIntegrationTest.java @@ -0,0 +1,56 @@ +package com.graphql.box.intro; + +import com.graphql.box.intro.config.GraphqlConfiguration; +import com.graphql.box.intro.controller.PostController; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.graphql.GraphQlTest; +import org.springframework.context.annotation.Import; +import org.springframework.graphql.test.tester.GraphQlTester; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@GraphQlTest(PostController.class) +@Import(GraphqlConfiguration.class) +class PostControllerIntegrationTest { + + @Autowired + private GraphQlTester graphQlTester; + + @Test + void givenPosts_whenExecuteQueryForRecentPosts_thenReturnResponse() { + String documentName = "recent_posts"; + + graphQlTester.documentName(documentName) + .variable("count", 2) + .variable("offset", 0) + .execute() + .path("$") + .matchesJson(expected(documentName)); + } + + @Test + void givenNewPostData_whenExecuteMutation_thenNewPostCreated() { + String documentName = "create_post"; + + graphQlTester.documentName(documentName) + .variable("title", "New Post") + .variable("text", "New post text") + .variable("category", "category") + .variable("authorId", "Author0") + .execute() + .path("createPost.id").hasValue() + .path("createPost.title").entity(String.class).isEqualTo("New Post") + .path("createPost.text").entity(String.class).isEqualTo("New post text") + .path("createPost.category").entity(String.class).isEqualTo("category"); + } + + @SneakyThrows + public static String expected(String fileName) { + Path path = Paths.get("src/test/resources/graphql-test/" + fileName + "_expected_response.json"); + return new String(Files.readAllBytes(path)); + } +} \ No newline at end of file diff --git a/src/test/java/com/graphql/box/intro/SpringContextTest.java b/src/test/java/com/graphql/box/intro/SpringContextTest.java new file mode 100644 index 0000000..ada2652 --- /dev/null +++ b/src/test/java/com/graphql/box/intro/SpringContextTest.java @@ -0,0 +1,12 @@ +package com.graphql.box.intro; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = GraphqlApplication.class) +class SpringContextTest { + + @Test + void whenSpringContextIsBootstrapped_thenNoExceptions() { + } +} diff --git a/src/test/resources/graphql-test/books_expected_response.json b/src/test/resources/graphql-test/books_expected_response.json new file mode 100644 index 0000000..066795e --- /dev/null +++ b/src/test/resources/graphql-test/books_expected_response.json @@ -0,0 +1,26 @@ +[ + { + "title": "Philosopher's Stone", + "year": 1997, + "author": { + "firstName": "Joanne", + "lastName": "Rowling" + } + }, + { + "title": "Goblet of Fire", + "year": 2000, + "author": { + "firstName": "Joanne", + "lastName": "Rowling" + } + }, + { + "title": "Deathly Hallows", + "year": 2007, + "author": { + "firstName": "Joanne", + "lastName": "Rowling" + } + } +] \ No newline at end of file diff --git a/src/test/resources/graphql-test/create_post.graphql b/src/test/resources/graphql-test/create_post.graphql new file mode 100644 index 0000000..fc6497a --- /dev/null +++ b/src/test/resources/graphql-test/create_post.graphql @@ -0,0 +1,8 @@ +mutation createPost ($title: String!, $text: String!, $category: String, $authorId: String!) { + createPost (title: $title, text: $text, category: $category, authorId: $authorId) { + id + title + text + category + } +} \ No newline at end of file diff --git a/src/test/resources/graphql-test/create_post_return_custom_scalar.graphql b/src/test/resources/graphql-test/create_post_return_custom_scalar.graphql new file mode 100644 index 0000000..9400903 --- /dev/null +++ b/src/test/resources/graphql-test/create_post_return_custom_scalar.graphql @@ -0,0 +1,3 @@ +mutation createPostReturnCustomScalar($title: String!, $text: String!, $category: String!, $author: String!) { + createPostReturnCustomScalar(title: $title, text: $text, category: $category, author: $author) +} diff --git a/src/test/resources/graphql-test/create_post_return_nullable_type.graphql b/src/test/resources/graphql-test/create_post_return_nullable_type.graphql new file mode 100644 index 0000000..f43c9bd --- /dev/null +++ b/src/test/resources/graphql-test/create_post_return_nullable_type.graphql @@ -0,0 +1,3 @@ +mutation createPostReturnNullableType($title: String!, $text: String!, $category: String!, $author: String!) { + createPostReturnNullableType(title: $title, text: $text, category: $category, author: $author) +} diff --git a/src/test/resources/graphql-test/recent_posts.graphql b/src/test/resources/graphql-test/recent_posts.graphql new file mode 100644 index 0000000..802661f --- /dev/null +++ b/src/test/resources/graphql-test/recent_posts.graphql @@ -0,0 +1,13 @@ +query recentPosts ($count: Int, $offset: Int) { + recentPosts (count: $count, offset: $offset) { + id + title + text + category + author { + id + name + thumbnail + } + } +} \ No newline at end of file diff --git a/src/test/resources/graphql-test/recent_posts_expected_response.json b/src/test/resources/graphql-test/recent_posts_expected_response.json new file mode 100644 index 0000000..0f49288 --- /dev/null +++ b/src/test/resources/graphql-test/recent_posts_expected_response.json @@ -0,0 +1,28 @@ +{ + "data": { + "recentPosts": [ + { + "id": "Post00", + "title": "Post 0:0", + "category": "Post category", + "text": "Post 0 + by author 0", + "author": { + "id": "Author0", + "name": "Author 0", + "thumbnail": "http://example.com/authors/0" + } + }, + { + "id": "Post10", + "title": "Post 1:0", + "category": "Post category", + "text": "Post 0 + by author 1", + "author": { + "id": "Author1", + "name": "Author 1", + "thumbnail": "http://example.com/authors/1" + } + } + ] + } +} \ No newline at end of file