Creare un'API GraphQL in Java

Questo articolo illustra un percorso chiaro per esporre un unico modello, Post, tramite GraphQL in Java. Useremo Spring Boot con Spring for GraphQL e JPA. Toccheremo schema, resolver, paginazione “page-based”, ordinamento per publishedAt, validazione, autorizzazione e CORS sullendpoint /graphql. Lobiettivo è una base pulita, facile da estendere ad altri modelli.

Stack e organizzazione

  • Runtime: Java 17+ (consigliato)
  • Framework: Spring Boot + Spring for GraphQL
  • Persistenza: Spring Data JPA (database a scelta; H2 per sviluppo)
  • Validazione: Jakarta Validation
  • Sicurezza (opzionale): Spring Security per popolare il SecurityContext

Schema GraphQL (SDL): tipo Post e operazioni

Posiziona lo schema in src/main/resources/graphql/schema.graphqls. Manteniamo il perimetro al solo Post, con paginazione e CRUD essenziali.

"""Rappresenta un contenuto pubblicato."""
type Post {
  id: ID!
  title: String!
  body: String!
  publishedAt: String
  createdAt: String!
  updatedAt: String!
}

"""Metadati di paginazione."""
type PaginatorInfo {
  count: Int!
  currentPage: Int!
  lastPage: Int!
  perPage: Int!
  total: Int!
  hasMorePages: Boolean!
}

"""Risultato paginato dei Post."""
type PostPaginator {
  data: [Post!]!
  paginatorInfo: PaginatorInfo!
}

type Query {
  posts(page: Int = 1, perPage: Int = 10, sort: String = "desc"): PostPaginator!
  post(id: ID!): Post
}

type Mutation {
  createPost(title: String!, body: String!, publishedAt: String): Post!
  updatePost(id: ID!, title: String, body: String, publishedAt: String): Post!
  deletePost(id: ID!): Boolean!
}

Dipendenze

Aggiungi le dipendenze in Maven o Gradle.

<!-- pom.xml (estratto) -->
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
  </dependency>
  <!-- Opzionale: sicurezza -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
</dependencies>
// build.gradle (estratto)
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-graphql'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  implementation 'org.springframework.boot:spring-boot-starter-validation'
  runtimeOnly 'com.h2database:h2'
  // opzionale
  implementation 'org.springframework.boot:spring-boot-starter-security'
}

Configurazione base

Imposta il path dellendpoint e abilita una console H2 per lo sviluppo.

# src/main/resources/application.properties
spring.graphql.path=/graphql
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:devdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
spring.jpa.hibernate.ddl-auto=update

Entità JPA Post

Un'entità essenziale con timestamp gestiti da callback e ordinamento su publishedAt.

// src/main/java/com/example/postapi/domain/Post.java
package com.example.postapi.domain;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "posts", indexes = {
  @Index(name = "idx_posts_published_at", columnList = "published_at")
})
public class Post {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, length = 255)
  private String title;

  @Lob @Column(nullable = false)
  private String body;

  @Column(name = "published_at")
  private Instant publishedAt;

  @Column(name = "created_at", nullable = false, updatable = false)
  private Instant createdAt;

  @Column(name = "updated_at", nullable = false)
  private Instant updatedAt;

  @PrePersist
  void onCreate() {
    Instant now = Instant.now();
    this.createdAt = now;
    this.updatedAt = now;
  }

  @PreUpdate
  void onUpdate() {
    this.updatedAt = Instant.now();
  }

  // getters e setters
  public Long getId() { return id; }
  public String getTitle() { return title; }
  public void setTitle(String title) { this.title = title; }
  public String getBody() { return body; }
  public void setBody(String body) { this.body = body; }
  public Instant getPublishedAt() { return publishedAt; }
  public void setPublishedAt(Instant publishedAt) { this.publishedAt = publishedAt; }
  public Instant getCreatedAt() { return createdAt; }
  public Instant getUpdatedAt() { return updatedAt; }
}

Repository

Usiamo Spring Data JPA per paginazione e ordinamento.

// src/main/java/com/example/postapi/repo/PostRepository.java
package com.example.postapi.repo;

import com.example.postapi.domain.Post;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
  // findAll(Pageable) già disponibile per paginare/ordinare
}

DTO per paginazione

Due semplici DTO per incapsulare lista e metadati; i nomi dei campi seguono lo schema GraphQL.

// src/main/java/com/example/postapi/graphql/dto/PaginatorInfo.java
package com.example.postapi.graphql.dto;

public class PaginatorInfo {
  public int count;
  public int currentPage;
  public int lastPage;
  public int perPage;
  public long total;
  public boolean hasMorePages;
}
// src/main/java/com/example/postapi/graphql/dto/PostPaginator.java
package com.example.postapi.graphql.dto;

import com.example.postapi.domain.Post;
import java.util.List;

public class PostPaginator {
  public List<Post> data;
  public PaginatorInfo paginatorInfo;

  public PostPaginator(List<Post> data, PaginatorInfo paginatorInfo) {
    this.data = data;
    this.paginatorInfo = paginatorInfo;
  }
}

Validazione input

Definiamo i payload delle mutation con Jakarta Validation.

// src/main/java/com/example/postapi/graphql/input/CreatePostInput.java
package com.example.postapi.graphql.input;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class CreatePostInput {
  @NotBlank @Size(max = 255)
  public String title;

  @NotBlank
  public String body;

  // ISO-8601 opzionale
  public String publishedAt;
}
// src/main/java/com/example/postapi/graphql/input/UpdatePostInput.java
package com.example.postapi.graphql.input;

import jakarta.validation.constraints.Size;

public class UpdatePostInput {
  public Long id;
  @Size(min = 1, max = 255)
  public String title;
  public String body;
  public String publishedAt;
}

Resolver GraphQL (Controller)

Implementiamo le query e le mutation. La paginazione usa PageRequest e lordinamento cade su publishedAt in modalità asc/desc. Lautorizzazione può essere applicata con Spring Security (ad esempio annotazioni @PreAuthorize) o controlli custom.

// src/main/java/com/example/postapi/graphql/PostGraphQLController.java
package com.example.postapi.graphql;

import com.example.postapi.domain.Post;
import com.example.postapi.repo.PostRepository;
import com.example.postapi.graphql.dto.PaginatorInfo;
import com.example.postapi.graphql.dto.PostPaginator;
import com.example.postapi.graphql.input.CreatePostInput;
import com.example.postapi.graphql.input.UpdatePostInput;
import jakarta.validation.Valid;
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.stereotype.Controller;
import org.springframework.validation.annotation.Validated;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Optional;

@Controller
@Validated
public class PostGraphQLController {

  private final PostRepository posts;

  public PostGraphQLController(PostRepository posts) {
    this.posts = posts;
  }

  @QueryMapping
  public PostPaginator posts(@Argument Integer page, @Argument Integer perPage, @Argument String sort) {
    int p = (page != null && page > 0) ? page : 1;
    int pp = (perPage != null && perPage > 0 && perPage <= 100) ? perPage : 10;
    boolean asc = "asc".equalsIgnoreCase(sort);
    Sort s = Sort.by("publishedAt");
    s = asc ? s.ascending() : s.descending();

    Pageable pageable = PageRequest.of(p - 1, pp, s);
    Page<Post> paged = posts.findAll(pageable);

    PaginatorInfo info = new PaginatorInfo();
    info.count = paged.getNumberOfElements();
    info.currentPage = p;
    info.lastPage = Math.max(1, paged.getTotalPages());
    info.perPage = pp;
    info.total = paged.getTotalElements();
    info.hasMorePages = paged.hasNext();

    return new PostPaginator(paged.getContent(), info);
  }

  @QueryMapping
  public Post post(@Argument Long id) {
    return posts.findById(id).orElse(null);
  }

  @MutationMapping
  public Post createPost(@Argument @Valid CreatePostInput input) {
    Post p = new Post();
    p.setTitle(input.title);
    p.setBody(input.body);
    if (input.publishedAt != null && !input.publishedAt.isBlank()) {
      try { p.setPublishedAt(Instant.parse(input.publishedAt)); }
      catch (DateTimeParseException ignored) {}
    }
    return posts.save(p);
  }

  @MutationMapping
  public Post updatePost(@Argument @Valid UpdatePostInput input) {
    Post current = posts.findById(input.id).orElseThrow();
    if (input.title != null) current.setTitle(input.title);
    if (input.body != null) current.setBody(input.body);
    if (input.publishedAt != null) {
      current.setPublishedAt(input.publishedAt.isBlank() ? null : Instant.parse(input.publishedAt));
    }
    return posts.save(current);
  }

  @MutationMapping
  public Boolean deletePost(@Argument Long id) {
    Optional<Post> found = posts.findById(id);
    if (found.isEmpty()) return false;
    posts.deleteById(id);
    return true;
  }
}

CORS sull'endpoint /graphql

Consenti gli origin necessari e il metodo OPTIONS per il preflight.

// src/main/java/com/example/postapi/config/WebConfig.java
package com.example.postapi.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
  @Override
  public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/graphql")
        .allowedOrigins("http://localhost:5173", "https://app.example.com")
        .allowedMethods("GET", "POST", "OPTIONS")
        .allowedHeaders("content-type", "authorization")
        .allowCredentials(true)
        .maxAge(600);
  }
}

Esempi dal punto di vista del client

Richiedi solo i campi necessari; una mutation che ritorna Boolean! non ammette subselection.

query {
  posts(page: 1, perPage: 5, sort: "desc") {
    data { id title publishedAt }
    paginatorInfo { currentPage lastPage perPage total hasMorePages count }
  }
}

query {
  post(id: 1) { id title body publishedAt }
}

mutation {
  createPost(title: "Titolo", body: "Testo", publishedAt: "2025-01-01T00:00:00Z") {
    id title publishedAt
  }
}

mutation {
  updatePost(id: 1, title: "Nuovo titolo") { id title updatedAt }
}

mutation {
  deletePost(id: 1)
}

Note su validazione, autorizzazione, performance

  • Validazione: usa Jakarta Validation su input e, se necessario, constraint custom.
  • Autorizzazione: applica regole con Spring Security (es. @PreAuthorize) o controlli nel controller.
  • Performance: indicizza published_at e limita perPage; per flussi continui valuta una paginazione a cursori.

Conclusione

Con pochi file ottieni un'API GraphQL in Java centrata su Post: schema chiaro, resolver snelli, paginazione e ordinamento prevedibili, validazione e CORS pronti per ambienti reali. Questa base si estende naturalmente ad altri modelli mantenendo coerenza e qualità.

Torna su