Class 11: Custom Scalars & File Uploads
Duration: 30 minutes Difficulty: Intermediate-Advanced Prerequisites: Completed Classes 1-10
What You'll Learn
By the end of this class, you will:
- Create custom scalar types (DateTime, Date, URL)
- Use the Extended Scalars library
- Handle file uploads in GraphQL
- Validate custom scalar values
- Format dates for different time zones
Why Custom Scalars?
GraphQL's built-in scalars (Int, Float, String, Boolean, ID) don't cover all use cases:
┌─────────────────────────────────────────────────────────────────────┐
│ CUSTOM SCALARS USE CASES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Need Solution │
│ ───────────────────────────────────────────────────────────────── │
│ Date/Time DateTime, Date, Time scalars │
│ Large numbers Long, BigInteger, BigDecimal │
│ URLs URL scalar with validation │
│ Email addresses Email scalar with validation │
│ JSON blobs JSON scalar │
│ File uploads Upload scalar │
│ Currency amounts Currency scalar │
│ UUIDs UUID scalar │
│ │
└─────────────────────────────────────────────────────────────────────┘
Step 1: Add Extended Scalars Library
Add the graphql-java-extended-scalars dependency:
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-extended-scalars</artifactId>
<version>21.0</version>
</dependency>
Step 2: Register Extended Scalars
📁 src/main/java/com/example/moviedb/config/ScalarConfig.java
package com.example.moviedb.config;
import graphql.scalars.ExtendedScalars;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
@Configuration
public class ScalarConfig {
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return wiringBuilder -> wiringBuilder
.scalar(ExtendedScalars.DateTime)
.scalar(ExtendedScalars.Date)
.scalar(ExtendedScalars.Time)
.scalar(ExtendedScalars.Url)
.scalar(ExtendedScalars.Json)
.scalar(ExtendedScalars.PositiveInt)
.scalar(ExtendedScalars.NonNegativeFloat);
}
}
Step 3: Update Schema with Custom Scalars
📁 Update src/main/resources/graphql/schema.graphqls:
# Declare custom scalars
scalar DateTime
scalar Date
scalar Time
scalar Url
scalar Json
scalar PositiveInt
scalar NonNegativeFloat
type Movie {
id: ID!
title: String!
releaseYear: Int!
genre: Genre!
rating: NonNegativeFloat
runtime: PositiveInt
plot: String
inTheaters: Boolean!
director: Director!
actors: [Actor!]!
# New fields with custom scalars
"Release date (just the date, no time)"
releaseDate: Date
"When this record was created"
createdAt: DateTime!
"When this record was last updated"
updatedAt: DateTime!
"Official movie poster URL"
posterUrl: Url
"Movie trailer URL"
trailerUrl: Url
"Additional metadata as JSON"
metadata: Json
}
type Actor {
id: ID!
name: String!
birthYear: Int
nationality: String
movies: [Movie!]!
"Actor's birth date"
birthDate: Date
"Actor's profile picture URL"
photoUrl: Url
}
input CreateMovieInput {
title: String!
releaseYear: Int!
genre: Genre!
rating: NonNegativeFloat
runtime: PositiveInt
plot: String
directorId: ID!
actorIds: [ID!]
# New fields
releaseDate: Date
posterUrl: Url
trailerUrl: Url
metadata: Json
}
Step 4: Update Models
📁 Update Movie.java:
package com.example.moviedb.model;
import java.net.URL;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
public class Movie {
private String id;
private String title;
private int releaseYear;
private Genre genre;
private Double rating;
private Integer runtime;
private String plot;
private boolean inTheaters;
private String directorId;
private List<String> actorIds;
// New fields
private LocalDate releaseDate;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private URL posterUrl;
private URL trailerUrl;
private Map<String, Object> metadata;
// Full constructor
public Movie(String id, String title, int releaseYear, Genre genre,
Double rating, Integer runtime, String plot, boolean inTheaters,
String directorId, List<String> actorIds,
LocalDate releaseDate, URL posterUrl, URL trailerUrl,
Map<String, Object> metadata) {
this.id = id;
this.title = title;
this.releaseYear = releaseYear;
this.genre = genre;
this.rating = rating;
this.runtime = runtime;
this.plot = plot;
this.inTheaters = inTheaters;
this.directorId = directorId;
this.actorIds = actorIds;
this.releaseDate = releaseDate;
this.createdAt = OffsetDateTime.now();
this.updatedAt = OffsetDateTime.now();
this.posterUrl = posterUrl;
this.trailerUrl = trailerUrl;
this.metadata = metadata;
}
// Backwards-compatible constructor
public Movie(String id, String title, int releaseYear, Genre genre,
Double rating, Integer runtime, String plot, boolean inTheaters,
String directorId, List<String> actorIds) {
this(id, title, releaseYear, genre, rating, runtime, plot, inTheaters,
directorId, actorIds, null, null, null, null);
}
// Getters
public String getId() { return id; }
public String getTitle() { return title; }
public int getReleaseYear() { return releaseYear; }
public Genre getGenre() { return genre; }
public Double getRating() { return rating; }
public Integer getRuntime() { return runtime; }
public String getPlot() { return plot; }
public boolean isInTheaters() { return inTheaters; }
public String getDirectorId() { return directorId; }
public List<String> getActorIds() { return actorIds; }
public LocalDate getReleaseDate() { return releaseDate; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public URL getPosterUrl() { return posterUrl; }
public URL getTrailerUrl() { return trailerUrl; }
public Map<String, Object> getMetadata() { return metadata; }
}
Step 5: Creating a Custom Scalar
Let's create a custom Duration scalar for runtime (more semantic than just Int):
📁 src/main/java/com/example/moviedb/scalar/DurationScalar.java
package com.example.moviedb.scalar;
import graphql.language.IntValue;
import graphql.language.StringValue;
import graphql.schema.*;
import java.time.Duration;
public class DurationScalar {
public static final GraphQLScalarType INSTANCE = GraphQLScalarType.newScalar()
.name("Duration")
.description("A duration in ISO-8601 format (e.g., 'PT2H30M' for 2 hours 30 minutes)")
.coercing(new Coercing<Duration, String>() {
@Override
public String serialize(Object dataFetcherResult) throws CoercingSerializeException {
if (dataFetcherResult instanceof Duration duration) {
return duration.toString(); // e.g., "PT2H30M"
}
if (dataFetcherResult instanceof Integer minutes) {
return Duration.ofMinutes(minutes).toString();
}
throw new CoercingSerializeException("Cannot serialize " + dataFetcherResult);
}
@Override
public Duration parseValue(Object input) throws CoercingParseValueException {
if (input instanceof String str) {
try {
return Duration.parse(str);
} catch (Exception e) {
throw new CoercingParseValueException("Invalid duration format: " + str);
}
}
if (input instanceof Integer minutes) {
return Duration.ofMinutes(minutes);
}
throw new CoercingParseValueException("Cannot parse duration from: " + input);
}
@Override
public Duration parseLiteral(Object input) throws CoercingParseLiteralException {
if (input instanceof StringValue stringValue) {
return Duration.parse(stringValue.getValue());
}
if (input instanceof IntValue intValue) {
return Duration.ofMinutes(intValue.getValue().intValue());
}
throw new CoercingParseLiteralException("Cannot parse duration literal");
}
})
.build();
}
Register it:
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return wiringBuilder -> wiringBuilder
.scalar(ExtendedScalars.DateTime)
.scalar(ExtendedScalars.Date)
.scalar(DurationScalar.INSTANCE) // Our custom scalar
// ... other scalars
;
}
Step 6: File Uploads
GraphQL doesn't have native file upload support, but there are patterns to handle it.
Option A: Separate REST Endpoint (Recommended)
The cleanest approach is to use REST for upload and GraphQL for metadata:
📁 src/main/java/com/example/moviedb/controller/FileUploadController.java
package com.example.moviedb.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/files")
public class FileUploadController {
private final Path uploadDir = Paths.get("uploads");
@PostMapping("/upload")
public ResponseEntity<Map<String, String>> uploadFile(@RequestParam("file") MultipartFile file) {
try {
Files.createDirectories(uploadDir);
String filename = UUID.randomUUID() + "_" + file.getOriginalFilename();
Path filePath = uploadDir.resolve(filename);
Files.copy(file.getInputStream(), filePath);
String fileUrl = "/uploads/" + filename;
return ResponseEntity.ok(Map.of(
"fileId", filename,
"url", fileUrl,
"originalName", file.getOriginalFilename(),
"size", String.valueOf(file.getSize())
));
} catch (IOException e) {
return ResponseEntity.internalServerError()
.body(Map.of("error", e.getMessage()));
}
}
}
Then use the file URL in GraphQL:
mutation {
updateMovie(id: "1", input: {
posterUrl: "https://example.com/uploads/abc123_poster.jpg"
}) {
id
posterUrl
}
}
Option B: GraphQL Multipart Upload
For direct GraphQL uploads, you can use the multipart specification:
📁 Add to schema:
scalar Upload
type Mutation {
# ... existing mutations
"Upload a movie poster"
uploadPoster(movieId: ID!, file: Upload!): Movie!
}
📁 Create Upload scalar:
package com.example.moviedb.scalar;
import graphql.schema.*;
import org.springframework.web.multipart.MultipartFile;
public class UploadScalar {
public static final GraphQLScalarType INSTANCE = GraphQLScalarType.newScalar()
.name("Upload")
.description("A file upload")
.coercing(new Coercing<MultipartFile, Void>() {
@Override
public Void serialize(Object dataFetcherResult) {
throw new CoercingSerializeException("Upload cannot be serialized");
}
@Override
public MultipartFile parseValue(Object input) {
if (input instanceof MultipartFile file) {
return file;
}
throw new CoercingParseValueException("Expected MultipartFile");
}
@Override
public MultipartFile parseLiteral(Object input) {
throw new CoercingParseLiteralException("Upload must be sent as multipart");
}
})
.build();
}
For most applications, Option A (separate REST endpoint) is recommended because:
- Simpler implementation
- Better HTTP support (progress, resumable uploads)
- Easier caching and CDN integration
- Standard browser file upload APIs work out of the box
Step 7: Test Custom Scalars
Restart your application and test:
Query with DateTime
query {
movie(id: "1") {
title
createdAt
updatedAt
}
}
Response:
{
"data": {
"movie": {
"title": "The Shawshank Redemption",
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
}
}
Mutation with URL
mutation {
updateMovie(id: "1", input: {
posterUrl: "https://example.com/posters/shawshank.jpg"
trailerUrl: "https://youtube.com/watch?v=abc123"
}) {
id
posterUrl
trailerUrl
}
}
Using JSON Scalar
mutation {
updateMovie(id: "1", input: {
metadata: {
awards: ["Oscar", "Golden Globe"],
budget: 25000000,
boxOffice: {
domestic: 28341469,
international: 30000000
}
}
}) {
id
metadata
}
}
Common Extended Scalars Reference
┌─────────────────────────────────────────────────────────────────────┐
│ EXTENDED SCALARS REFERENCE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Scalar │ Java Type │ Example │
│ ───────────────────────────────────────────────────────────────── │
│ DateTime │ OffsetDateTime │ "2024-01-15T10:30:00Z" │
│ Date │ LocalDate │ "2024-01-15" │
│ Time │ LocalTime │ "10:30:00" │
│ Url │ URL │ "https://example.com" │
│ UUID │ UUID │ "123e4567-e89b-..." │
│ Json │ Map<String,Object> │ {"key": "value"} │
│ PositiveInt │ Integer (> 0) │ 42 │
│ NonNegativeInt │ Integer (>= 0) │ 0 │
│ NegativeInt │ Integer (< 0) │ -1 │
│ Long │ Long │ 9223372036854775807 │
│ BigDecimal │ BigDecimal │ "123.456789" │
│ │
└─────────────────────────────────────────────────────────────────────┘
Exercises
Exercise 1: Email Scalar
Create a custom Email scalar that validates email format.
Solution
public class EmailScalar {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[A-Za-z0-9+_.-]+@(.+)$");
public static final GraphQLScalarType INSTANCE = GraphQLScalarType.newScalar()
.name("Email")
.coercing(new Coercing<String, String>() {
@Override
public String serialize(Object result) {
return result.toString();
}
@Override
public String parseValue(Object input) {
String email = input.toString();
if (!EMAIL_PATTERN.matcher(email).matches()) {
throw new CoercingParseValueException("Invalid email: " + email);
}
return email;
}
@Override
public String parseLiteral(Object input) {
if (input instanceof StringValue sv) {
return parseValue(sv.getValue());
}
throw new CoercingParseLiteralException("Expected string");
}
})
.build();
}
Exercise 2: Currency Scalar
Create a Currency scalar that formats monetary values with currency symbol.
Exercise 3: Add createdAt/updatedAt to Actors
Add timestamp fields to the Actor type and automatically set them on create/update.
Summary
In this class, you learned:
✅ Using the Extended Scalars library ✅ Registering custom scalars in Spring GraphQL ✅ Creating your own custom scalar types ✅ File upload strategies for GraphQL ✅ When to use custom scalars vs. regular types
What's Next?
In Class 12: Production Ready, we'll learn:
- Performance monitoring
- Caching strategies
- Logging and observability
- Deployment best practices
Time to ship to production!