A complete GraphQL-based CRUD (Create, Read, Update, Delete) API built with Java, Spring Boot, and H2 database with comprehensive testing support using Postman and RestAssured.
- âś… Full CRUD operations for User entity
- âś… GraphQL API with comprehensive schema
- âś… Input validation and error handling
- âś… Custom exception handling
- âś… H2 in-memory database for development
- âś… GraphiQL interface for testing
- âś… Unit tests with Spring Boot Test
- âś… Initial data loading
- âś… Search and filtering capabilities
- âś… Proper layered architecture
- âś… Postman collection for API testing
- âś… RestAssured integration tests
- Java 17
- Spring Boot 3.2.0
- Spring Data JPA
- Spring GraphQL
- H2 Database
- Maven
- JUnit 5
- RestAssured (for API testing)
- GraphQL Java Extended Scalars
src/
├── main/
│ ├── java/com/example/graphqlcrudapi/
│ │ ├── GraphqlCrudApiApplication.java # Main application class
│ │ ├── config/
│ │ │ ├── DataLoader.java # Initial data setup
│ │ │ ├── GlobalExceptionHandler.java # Error handling
│ │ │ └── GraphQLConfig.java # GraphQL configuration
│ │ ├── controller/
│ │ │ └── UserController.java # GraphQL resolvers
│ │ ├── dto/
│ │ │ └── UserInput.java # Input DTOs
│ │ ├── entity/
│ │ │ └── User.java # JPA entity
│ │ ├── exception/
│ │ │ ├── UserNotFoundException.java # Custom exceptions
│ │ │ └── DuplicateEmailException.java
│ │ ├── repository/
│ │ │ └── UserRepository.java # Data access layer
│ │ └── service/
│ │ └── UserService.java # Business logic
│ └── resources/
│ ├── application.yml # Configuration
│ └── graphql/
│ └── schema.graphqls # GraphQL schema
└── test/
└── java/com/example/graphqlcrudapi/
└── controller/
└── UserControllerTest.java # Unit tests
- Java 17 or higher
- Maven 3.6 or higher
-
Clone the repository
git clone <repository-url> cd graphql-crud-api
-
Build the project
mvn clean compile
-
Run the application
mvn spring-boot:run
-
Access the application
- GraphiQL Interface: http://localhost:8089/graphiql
- GraphQL Endpoint: http://localhost:8089/graphql
- H2 Console: http://localhost:8089/h2-console
- JDBC URL:
jdbc:h2:mem:testdb
- Username:
sa
- Password: (empty)
- Install Postman: Download from postman.com
- RestAssured Dependencies: Already included in the project for automated testing
- Create a new request in Postman
- Set method to POST
- Set URL to:
http://localhost:8089/graphql
- Set Headers:
Content-Type: application/json
Accept: application/json
All GraphQL requests use the same POST endpoint with a JSON body:
{
"query": "YOUR_QUERY_OR_MUTATION_HERE",
"variables": {
"variable1": "value1",
"variable2": "value2"
}
}
{
"query": "mutation CreateUser($input: UserInput!) { createUser(input: $input) { id name email phone address createdAt updatedAt } }",
"variables": {
"input": {
"name": "John Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"address": "123 Main St, New York, NY"
}
}
}
{
"data": {
"createUser": {
"id": "11",
"name": "John Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"address": "123 Main St, New York, NY",
"createdAt": "2025-09-21T10:30:00.123456",
"updatedAt": "2025-09-21T10:30:00.123456"
}
}
}
@Test
void createUserTest() {
String mutation = """
mutation CreateUser($input: UserInput!) {
createUser(input: $input) {
id
name
email
phone
address
}
}
""";
Map<String, Object> variables = Map.of(
"input", Map.of(
"name", "John Doe",
"email", "john.doe@example.com",
"phone", "+1234567890",
"address", "123 Main St, New York, NY"
)
);
given()
.contentType(ContentType.JSON)
.body(Map.of("query", mutation, "variables", variables))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.createUser.name", equalTo("John Doe"))
.body("data.createUser.email", equalTo("john.doe@example.com"));
}
{
"query": "query { getAllUsers { id name email phone address createdAt updatedAt } }"
}
{
"data": {
"getAllUsers": [
{
"id": "1",
"name": "John Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"address": "123 Main St, New York, NY",
"createdAt": "2025-09-21T10:00:00.123456",
"updatedAt": "2025-09-21T10:00:00.123456"
}
]
}
}
@Test
void getAllUsersTest() {
String query = "query { getAllUsers { id name email phone address } }";
given()
.contentType(ContentType.JSON)
.body(Map.of("query", query))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.getAllUsers", hasSize(greaterThan(0)))
.body("data.getAllUsers[0].id", notNullValue());
}
{
"query": "query GetUser($id: ID!) { getUserById(id: $id) { id name email phone address createdAt updatedAt } }",
"variables": {
"id": "1"
}
}
{
"data": {
"getUserById": {
"id": "1",
"name": "John Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"address": "123 Main St, New York, NY",
"createdAt": "2025-09-21T10:00:00.123456",
"updatedAt": "2025-09-21T10:00:00.123456"
}
}
}
@Test
void getUserByIdTest() {
String query = """
query GetUser($id: ID!) {
getUserById(id: $id) {
id
name
email
}
}
""";
given()
.contentType(ContentType.JSON)
.body(Map.of("query", query, "variables", Map.of("id", "1")))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.getUserById.id", equalTo("1"))
.body("data.getUserById.name", notNullValue());
}
{
"query": "query SearchUsers($name: String!) { searchUsersByName(name: $name) { id name email phone address } }",
"variables": {
"name": "John"
}
}
{
"data": {
"searchUsersByName": [
{
"id": "1",
"name": "John Doe",
"email": "john.doe@example.com",
"phone": "+1234567890",
"address": "123 Main St, New York, NY"
}
]
}
}
{
"query": "query SearchUsers($name: String, $email: String, $phone: String) { searchUsers(name: $name, email: $email, phone: $phone) { id name email phone address } }",
"variables": {
"name": "John",
"email": "example.com",
"phone": null
}
}
{
"query": "query { getUserCount }"
}
{
"data": {
"getUserCount": 10
}
}
{
"query": "mutation UpdateUser($id: ID!, $input: UserInput!) { updateUser(id: $id, input: $input) { id name email phone address updatedAt } }",
"variables": {
"id": "1",
"input": {
"name": "John Smith Updated",
"email": "john.smith.updated@example.com",
"phone": "+9876543210",
"address": "456 Updated Street, Los Angeles, CA"
}
}
}
{
"data": {
"updateUser": {
"id": "1",
"name": "John Smith Updated",
"email": "john.smith.updated@example.com",
"phone": "+9876543210",
"address": "456 Updated Street, Los Angeles, CA",
"updatedAt": "2025-09-21T11:00:00.123456"
}
}
}
@Test
void updateUserTest() {
String mutation = """
mutation UpdateUser($id: ID!, $input: UserInput!) {
updateUser(id: $id, input: $input) {
id
name
email
phone
address
}
}
""";
Map<String, Object> variables = Map.of(
"id", "1",
"input", Map.of(
"name", "John Smith Updated",
"email", "john.updated@example.com",
"phone", "+9876543210",
"address", "456 Updated Street"
)
);
given()
.contentType(ContentType.JSON)
.body(Map.of("query", mutation, "variables", variables))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.updateUser.name", equalTo("John Smith Updated"))
.body("data.updateUser.email", equalTo("john.updated@example.com"));
}
{
"query": "mutation DeleteUser($id: ID!) { deleteUser(id: $id) }",
"variables": {
"id": "1"
}
}
{
"data": {
"deleteUser": true
}
}
@Test
void deleteUserTest() {
String mutation = """
mutation DeleteUser($id: ID!) {
deleteUser(id: $id)
}
""";
given()
.contentType(ContentType.JSON)
.body(Map.of("query", mutation, "variables", Map.of("id", "1")))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.deleteUser", equalTo(true));
}
{
"query": "query CheckUser($id: ID!) { userExists(id: $id) }",
"variables": {
"id": "1"
}
}
{
"query": "query CheckEmail($email: String!) { emailExists(email: $email) }",
"variables": {
"email": "john.doe@example.com"
}
}
{
"query": "query GetUser($id: ID!) { getUserById(id: $id) { id name } }",
"variables": {
"id": "999"
}
}
{
"errors": [
{
"message": "User not found with id: 999",
"locations": [
{
"line": 1,
"column": 25
}
],
"path": ["getUserById"],
"extensions": {
"classification": "NOT_FOUND"
}
}
],
"data": {
"getUserById": null
}
}
{
"query": "mutation CreateUser($input: UserInput!) { createUser(input: $input) { id } }",
"variables": {
"input": {
"name": "Duplicate User",
"email": "john.doe@example.com",
"phone": "+1111111111"
}
}
}
{
"errors": [
{
"message": "Email already exists: john.doe@example.com",
"locations": [
{
"line": 1,
"column": 43
}
],
"path": ["createUser"],
"extensions": {
"classification": "BAD_REQUEST"
}
}
],
"data": {
"createUser": null
}
}
{
"query": "mutation CreateUser($input: UserInput!) { createUser(input: $input) { id } }",
"variables": {
"input": {
"name": "A",
"email": "invalid-email",
"phone": "+1234567890123456789"
}
}
}
{
"errors": [
{
"message": "Validation failed: Name must be between 2 and 100 characters; Email must be valid; Phone number cannot exceed 15 characters; ",
"locations": [
{
"line": 1,
"column": 43
}
],
"path": ["createUser"],
"extensions": {
"classification": "BAD_REQUEST"
}
}
],
"data": {
"createUser": null
}
}
Create a new test class GraphQLApiIntegrationTest.java
:
package com.example.graphqlcrudapi.integration;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.annotation.DirtiesContext;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
public class GraphQLApiIntegrationTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
RestAssured.basePath = "";
}
@Test
@Order(1)
void shouldGetAllUsers() {
String query = "query { getAllUsers { id name email phone address } }";
given()
.contentType(ContentType.JSON)
.body(Map.of("query", query))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.getAllUsers", hasSize(10))
.body("data.getAllUsers[0].id", notNullValue())
.body("data.getAllUsers[0].name", notNullValue());
}
@Test
@Order(2)
void shouldCreateUser() {
String mutation = """
mutation CreateUser($input: UserInput!) {
createUser(input: $input) {
id
name
email
phone
address
}
}
""";
Map<String, Object> variables = Map.of(
"input", Map.of(
"name", "RestAssured User",
"email", "restassured@example.com",
"phone", "+1111111111",
"address", "123 RestAssured St"
)
);
given()
.contentType(ContentType.JSON)
.body(Map.of("query", mutation, "variables", variables))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.createUser.name", equalTo("RestAssured User"))
.body("data.createUser.email", equalTo("restassured@example.com"))
.body("data.createUser.phone", equalTo("+1111111111"));
}
@Test
@Order(3)
void shouldGetUserById() {
String query = """
query GetUser($id: ID!) {
getUserById(id: $id) {
id
name
email
}
}
""";
given()
.contentType(ContentType.JSON)
.body(Map.of("query", query, "variables", Map.of("id", "1")))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.getUserById.id", equalTo("1"))
.body("data.getUserById.name", notNullValue())
.body("data.getUserById.email", notNullValue());
}
@Test
@Order(4)
void shouldSearchUsersByName() {
String query = """
query SearchUsers($name: String!) {
searchUsersByName(name: $name) {
id
name
email
}
}
""";
given()
.contentType(ContentType.JSON)
.body(Map.of("query", query, "variables", Map.of("name", "John")))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.searchUsersByName", hasSize(greaterThan(0)))
.body("data.searchUsersByName[0].name", containsString("John"));
}
@Test
@Order(5)
void shouldUpdateUser() {
String mutation = """
mutation UpdateUser($id: ID!, $input: UserInput!) {
updateUser(id: $id, input: $input) {
id
name
email
phone
address
}
}
""";
Map<String, Object> variables = Map.of(
"id", "1",
"input", Map.of(
"name", "Updated User",
"email", "updated@example.com",
"phone", "+9999999999",
"address", "999 Updated Ave"
)
);
given()
.contentType(ContentType.JSON)
.body(Map.of("query", mutation, "variables", variables))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.updateUser.name", equalTo("Updated User"))
.body("data.updateUser.email", equalTo("updated@example.com"));
}
@Test
@Order(6)
void shouldGetUserCount() {
String query = "query { getUserCount }";
given()
.contentType(ContentType.JSON)
.body(Map.of("query", query))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.getUserCount", greaterThan(10));
}
@Test
@Order(7)
void shouldDeleteUser() {
String mutation = """
mutation DeleteUser($id: ID!) {
deleteUser(id: $id)
}
""";
given()
.contentType(ContentType.JSON)
.body(Map.of("query", mutation, "variables", Map.of("id", "1")))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.deleteUser", equalTo(true));
}
@Test
@Order(8)
void shouldReturnErrorForNonExistentUser() {
String query = """
query GetUser($id: ID!) {
getUserById(id: $id) {
id
name
}
}
""";
given()
.contentType(ContentType.JSON)
.body(Map.of("query", query, "variables", Map.of("id", "999")))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("errors", hasSize(1))
.body("errors[0].message", containsString("User not found"))
.body("data.getUserById", nullValue());
}
@Test
@Order(9)
void shouldReturnValidationError() {
String mutation = """
mutation CreateUser($input: UserInput!) {
createUser(input: $input) {
id
}
}
""";
Map<String, Object> variables = Map.of(
"input", Map.of(
"name", "A", // Too short
"email", "invalid-email", // Invalid format
"phone", "+12345678901234567890" // Too long
)
);
given()
.contentType(ContentType.JSON)
.body(Map.of("query", mutation, "variables", variables))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("errors", hasSize(1))
.body("errors[0].message", containsString("Validation failed"));
}
}
Create a Postman environment with these variables:
base_url
:http://localhost:8089
user_id
:1
(for testing)test_email
:test@example.com
For dynamic testing, add this pre-request script:
// Generate random user data
pm.globals.set("random_name", "User_" + Math.floor(Math.random() * 1000));
pm.globals.set("random_email", "user" + Math.floor(Math.random() * 1000) + "@example.com");
pm.globals.set("random_phone", "+1" + Math.floor(Math.random() * 1000000000));
Add this test script to verify responses:
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});
pm.test("Response has data", function () {
const jsonData = pm.response.json();
pm.expect(jsonData).to.have.property('data');
});
pm.test("No GraphQL errors", function () {
const jsonData = pm.response.json();
if (jsonData.errors) {
pm.expect(jsonData.errors).to.have.length(0);
}
});
- Always test CREATE before UPDATE/DELETE
- Test READ operations first to understand data structure
- Test error scenarios after successful operations
- Use unique identifiers for test data
- Clean up test data after tests
- Use database transactions in tests when possible
- Verify both success and error responses
- Check response structure and data types
- Validate business logic constraints
- Use different databases for testing
- Set up proper test data fixtures
- Configure appropriate logging levels
query {
getAllUsers {
id
name
email
phone
address
createdAt
updatedAt
}
}
query {
getUserById(id: "1") {
id
name
email
phone
address
}
}
query {
searchUsersByName(name: "John") {
id
name
email
}
}
query {
searchUsers(name: "John", email: "example.com") {
id
name
email
phone
}
}
query {
getUserCount
}
query {
userExists(id: "1")
emailExists(email: "john.doe@example.com")
}
mutation {
createUser(input: {
name: "New User"
email: "newuser@example.com"
phone: "+1234567890"
address: "123 New Street"
}) {
id
name
email
phone
address
createdAt
}
}
mutation {
updateUser(id: "1", input: {
name: "Updated Name"
email: "updated@example.com"
phone: "+9876543210"
address: "456 Updated Street"
}) {
id
name
email
phone
address
updatedAt
}
}
mutation {
deleteUser(id: "1")
}
{
"id": "Long (Primary Key)",
"name": "String (Required, 2-100 chars)",
"email": "String (Required, Valid email, Unique)",
"phone": "String (Optional, Max 15 chars)",
"address": "String (Optional, Max 500 chars)",
"createdAt": "DateTime (Auto-generated)",
"updatedAt": "DateTime (Auto-updated)"
}
- Name: Required, 2-100 characters
- Email: Required, valid email format, unique
- Phone: Optional, maximum 15 characters
- Address: Optional, maximum 500 characters
The API provides comprehensive error handling for:
- Validation Errors: Invalid input data
- Not Found Errors: User does not exist
- Duplicate Email Errors: Email already exists
- Bad Request Errors: Invalid arguments
- Internal Server Errors: Unexpected errors
mvn test
mvn verify
The project includes comprehensive unit tests covering:
- All CRUD operations
- Search functionality
- Error scenarios
- Validation rules
mutation {
user1: createUser(input: {
name: "Alice Johnson"
email: "alice@example.com"
phone: "+1111111111"
address: "123 Alice St"
}) { id name }
user2: createUser(input: {
name: "Bob Smith"
email: "bob@example.com"
phone: "+2222222222"
address: "456 Bob Ave"
}) { id name }
}
query {
allUsers: getAllUsers { id name email }
userCount: getUserCount
johnUsers: searchUsersByName(name: "John") { id name }
}
- Hot Reload: Enabled with Spring Boot DevTools
- SQL Logging: Enabled for development debugging
- GraphiQL: Interactive GraphQL IDE
- H2 Console: Database inspection tool
- Comprehensive Logging: Debug information for troubleshooting
For production deployment, consider:
- Database: Replace H2 with PostgreSQL/MySQL
- Security: Add authentication and authorization
- Caching: Implement Redis for better performance
- Monitoring: Add metrics and health checks
- Rate Limiting: Implement API rate limiting
- Validation: Enhanced input validation
- Documentation: API documentation generation
- Fork the repository
- Create a feature branch
- Make changes and add tests
- Ensure all tests pass
- Submit a pull request
This project is licensed under the MIT License.
For questions or issues, please create an issue in the repository.