Skip to main content

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.

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();
}
File Upload Recommendation

For most applications, Option A (separate REST endpoint) is recommended because:

  1. Simpler implementation
  2. Better HTTP support (progress, resumable uploads)
  3. Easier caching and CDN integration
  4. 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!