Pulpogato is a java client for GitHub.
Installation
For releases, use mavenCentral.
https://repo.maven.apache.org/maven2/
For snapshots, use this url.
https://central.sonatype.com/repository/maven-snapshots/
The maven group id is io.github.pulpogato.
Most maven artifact ids are pulpogato-<api-type>-<gh-version>.
The api-type is one of graphql or rest.
The gh-version is one of ghec,fpt, or ghes-<GHES-version>.
To align versions across all Pulpogato modules, use io.github.pulpogato:pulpogato-bom.
Example in a gradle kotlin build script
// These properties can also be set in gradle.properties
ext {
set("netflixDgsVersion", "9.1.2")
set("ghVersion", "fpt")
set("pulpogatoVersion", "2.8.0")
}
val ghVersion: String by project.ext
val pulpogatoVersion: String by project.ext
val netflixDgsVersion: String by project.ext
dependencies {
implementation(platform("io.github.pulpogato:pulpogato-bom:${pulpogatoVersion}"))
// Use enforcedPlatform(...) instead of platform(...) for strict version enforcement.
implementation("io.github.pulpogato:pulpogato-rest-${ghVersion}")
implementation("io.github.pulpogato:pulpogato-graphql-${ghVersion}")
implementation("io.github.pulpogato:pulpogato-github-files")
implementation("org.springframework.boot:spring-boot-starter-webflux:4.0.2")
implementation("com.netflix.graphql.dgs:graphql-dgs-client:11.1.0")
implementation(platform("com.netflix.graphql.dgs:graphql-dgs-platform-dependencies:${netflixDgsVersion}"))
}
Available versions
| GitHub Version | GraphQL | REST |
|---|---|---|
EOL Versions
Shared modules
| Module | Version |
|---|---|
pulpogato-common Shared types used by both REST and GraphQL modules, such as common models and utilities. |
|
pulpogato-bom BOM to align versions across all Pulpogato modules. It is recommended to use this BOM in your build system to ensure all Pulpogato modules are on the same version. |
|
pulpogato-github-files Supports parsing Actions, Workflows, and Release configuration files. |
Usage
REST Client Example
This shows how to create a REST client and get the authenticated user. Ideally, you would inject the WebClient and export a Bean for RestClients. If you’re supporting multiple GitHub Apps or sites, you would export a factory bean instead.
import io.github.pulpogato.rest.api.RestClients;
import io.github.pulpogato.rest.api.UsersApi;
public class RestExample {
public static void main(String[] args) {
// create a WebClient
WebClient webClient = WebClient.builder()
.baseUrl("https://api.github.com")
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer ...your token here...")
.build();
// Create RestClients instance
RestClients restClients = new RestClients(webClient);
// Get UsersApi
UsersApi api = restClients.getUsersApi();
// Call getAuthenticated method
var authenticated = api.getAuthenticated();
var body = authenticated.getBody();
var privateUser = body.getPrivateUser();
var login = privateUser.getLogin();
}
}
Reactive REST Client Example
This shows how to create a REST client that returns a Mono reactive type.
import io.github.pulpogato.rest.api.reactive.RestClients;
import io.github.pulpogato.rest.api.reactive.UsersApi;
public class ReactiveRestExample {
public static void main(String[] args) {
// create a WebClient
WebClient webClient = WebClient.builder()
.baseUrl("https://api.github.com")
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer ...your token here...")
.build();
// Create RestClients instance
RestClients restClients = new RestClients(webClient);
// Get UsersApi
UsersApi api = restClients.getUsersApi();
// Call getAuthenticated method to obtain a Mono object
var responseEntityMono = api.getAuthenticated();
// Call block() to subscribe to the Mono and block
// until the call response is received.
var authenticated = responseEntityMono.block();
var body = authenticated.getBody();
var privateUser = body.getPrivateUser();
var login = privateUser.getLogin();
}
}
Webhook Example
This shows how to create a Webhook handler that listens for ping events. There are many other event types you can listen for.
@RestController
@RequestMapping("/webhooks")
@RequiredArgsConstructor
public class PingWebhooksController implements PingWebhooks<TestWebhookResponse> {
private final ObjectMapper objectMapper;
@Override
public ResponseEntity<TestWebhookResponse> processPing(
String userAgent,
String xGithubHookId,
String xGithubEvent,
String xGithubHookInstallationTargetId,
String xGithubHookInstallationTargetType,
String xGitHubDelivery,
String xHubSignature256,
WebhookPing requestBody) {
return ResponseEntity.ok(TestWebhookResponse.builder()
.webhookName("ping")
.body(objectMapper.writeValueAsString(requestBody))
.build());
}
}
GraphQL Client Example
This shows how to create a GraphQL client and get the authenticated user. Ideally, you would inject the WebClient and export a Bean for GraphQLClients. If you’re supporting multiple GitHub Apps or sites, you would export a factory bean instead.
This example uses a String for the query.
public class GraphQlExample {
@Language("graphql")
public static final String OPEN_PRS = """
query findOpenPullRequests($owner: String!, $repo: String!, $branch: String!) {
repository(owner: $owner, name: $repo, followRenames: false) {
pullRequests(
first: 100, states: [OPEN], baseRefName: $branch,
orderBy: {field: UPDATED_AT, direction: DESC}
) {
totalCount
nodes {
id
number
headRefName
headRefOid
baseRefOid
mergeable
author {
__typename
login
}
url
autoMergeRequest {
enabledBy {
__typename
login
}
mergeMethod
}
}
}
}
}
""";
public static void main(String[] args) {
// create a WebClient
WebClient graphqlWebClient = WebClient.builder()
.baseUrl("https://api.github.com/graphql")
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer ...your token here...")
.build();
WebClientGraphQLClient graphQLClient = new WebClientGraphQLClient(graphqlWebClient);
var variables = LinkedHashMapBuilder.of(
entry("owner", "pulpogato"), (1)
entry("repo", "pulpogato"),
entry("branch", "main"));
var response = graphQLClient.reactiveExecuteQuery(OPEN_PRS, variables).block();
var repository = response.extractValueAsObject("repository", Repository.class);
}
}
| 1 | Set variables for the query |
This is the same query using a Typed Query Builder
public class TypedGraphQlExample {
public static void main(String[] args) {
// create a WebClient
WebClient graphqlWebClient = WebClient.builder()
.baseUrl("https://api.github.com/graphql")
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer ...your token here...")
.build();
WebClientGraphQLClient graphQLClient = new WebClientGraphQLClient(graphqlWebClient);
var query = RepositoryGraphQLQuery.newRequest()
.followRenames(false)
.owner("pulpogato")
.name("pulpogato")
.build();
var repoProjection = new RepositoryProjectionRoot<>();
var prProjection = repoProjection.pullRequests(
/*after*/ null,
/*baseRefName*/ "main",
/*before*/ null,
/*first*/ 100,
/*headRefName*/ null,
/*labels*/ null,
/*last*/ null,
IssueOrder.newBuilder()
.field(IssueOrderField.UPDATED_AT)
.direction(OrderDirection.DESC)
.build(),
/*states*/ null);
var nodesProjection = prProjection.totalCount().nodes();
nodesProjection
/* enter */
.id()
.number()
.headRefName()
.headRefOid()
.baseRefOid()
.url()
.mergeable();
nodesProjection
/* enter */
.author()
.__typename()
.login();
var autoMergeRequest = nodesProjection
/* enter */
.autoMergeRequest();
autoMergeRequest
/* enter */
.mergeMethod();
autoMergeRequest
/* enter */
.enabledBy()
/* enter */
.__typename()
.login();
var typedQuery = new GraphQLQueryRequest(query, repoProjection);
var response =
graphQLClient.reactiveExecuteQuery(typedQuery.serialize()).block();
var repository = response.extractValueAsObject("repository", Repository.class);
}
}
GitHub Files Example
pulpogato-github-files provides typed models for common repository files:
-
Action metadata (
action.yml) asGithubAction -
Discussion forms (
.github/DISCUSSION_TEMPLATE/*.yml) asGithubDiscussion -
Funding configuration (
.github/FUNDING.yml) asGithubFunding -
Issue Template Chooser config (
.github/ISSUE_TEMPLATE/config.yml) asGithubIssueConfig -
Issue forms (
.github/ISSUE_TEMPLATE/*.yml) asGithubIssueForms -
Release Drafter config (
.github/release.yml) asGithubReleaseConfig -
Workflow files (
.github/workflows/*.yml) asGithubWorkflow -
Workflow template properties (
.github/workflow-templates/*.properties.json) asGithubWorkflowTemplateProperties
The module supports both Jackson 2 (com.fasterxml.jackson) and Jackson 3 (tools.jackson).
Dependency:
implementation("io.github.pulpogato:pulpogato-github-files:<version>")
Jackson 2 YAML parsing example:
var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
var workflowYaml = """
name: CI
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: ./gradlew test
""";
var workflow = yamlMapper.readValue(workflowYaml, GithubWorkflow.class);
var workflowName = workflow.getName();
Jackson 3 YAML parsing example:
var yamlMapper = tools.jackson.dataformat.yaml.YAMLMapper.builder().build();
var actionYaml = """
name: Setup Java
description: Setup JDK
runs:
using: node20
main: index.js
""";
var action = yamlMapper.readValue(actionYaml, GithubAction.class);
var actionName = action.getName();
Funding parsing example:
var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
var fundingYaml = """
github: [octocat, hubot]
patreon: octocat
""";
var funding = yamlMapper.readValue(fundingYaml, GithubFunding.class);
var githubSponsors = funding.getGithub().getList();
Issue forms parsing example:
var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
var issueFormsYaml = """
name: Bug Report
description: File a bug report
body:
- type: input
id: version
attributes:
label: Version
""";
var issueForms = yamlMapper.readValue(issueFormsYaml, GithubIssueForms.class);
var name = issueForms.getName();
Release Drafter parsing example:
var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
var releaseYaml = """
changelog:
categories:
- title: Features
labels: [feature]
""";
var releaseConfig = yamlMapper.readValue(releaseYaml, GithubReleaseConfig.class);
var firstCategory = releaseConfig.getChangelog().getCategories().get(0).getTitle();
Discussion form parsing example:
var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
var discussionYaml = """
title: New Feature Idea
body:
- type: input
attributes:
label: Idea name
placeholder: My cool idea
""";
var discussion =
yamlMapper.readValue(discussionYaml, io.github.pulpogato.githubfiles.discussion.GithubDiscussion.class);
var title = discussion.getTitle();
Issue Template Chooser parsing example:
var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
var issueConfigYaml = """
blank_issues_enabled: false
contact_links:
- name: Community Support
url: https://community.example.com
about: Get help here
""";
var issueConfig = yamlMapper.readValue(
issueConfigYaml, io.github.pulpogato.githubfiles.issueconfig.GithubIssueConfig.class);
var enabled = issueConfig.getBlankIssuesEnabled();
Workflow template properties parsing example:
var yamlMapper = new com.fasterxml.jackson.databind.ObjectMapper(
new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
var templatePropertiesYaml = """
name: Java CI
description: Java CI with Gradle
iconName: java
categories: [Java]
""";
var templateProperties = yamlMapper.readValue(templatePropertiesYaml, GithubWorkflowTemplateProperties.class);
var iconName = templateProperties.getIconName();
Many fields in these schemas are unions (for example, on in workflows can be a string, list, or object).
These are represented as typed wrapper objects with one populated field per variant.
var on = workflow.getOn();
if (on.getEvent() != null) {
assertThat(on.getEvent()).isEqualTo(Event.PUSH);
} else if (on.getList() != null) {
assertThat(on.getList()).contains(Event.PUSH);
} else if (on.getGithubWorkflowOnVariant2() != null) {
assertThat(on.getGithubWorkflowOnVariant2()).isNotNull();
}
GitHub App JWT Authentication
Pulpogato provides JwtFilter to authenticate as a GitHub App using JSON Web Tokens (JWT).
This is useful when you need to authenticate as the app itself rather than as an installation.
JWT Filter Setup
var jwtFactory = new JwtFactory(
privateKeyPem, (1)
12345L (2)
);
var jwtFilter = JwtFilter.builder().jwtFactory(jwtFactory).build();
var webClient = WebClient.builder()
.baseUrl("https://api.github.com")
.filter(jwtFilter) (3)
.build();
| 1 | The RSA private key in PEM format (PKCS#1 or PKCS#8), loaded from your GitHub App settings |
| 2 | Your GitHub App ID (or client ID) as a string |
| 3 | Add the filter to your WebClient |
The filter:
-
Generates RS256-signed JWTs per GitHub’s requirements
-
Sets
iat(issued at) 60 seconds in the past to account for clock drift -
Sets
exp(expiration) to 9 minutes in the future -
Caches tokens and refreshes them 30 seconds before expiry
-
Supports both PKCS#1 (
BEGIN RSA PRIVATE KEY) and PKCS#8 (BEGIN PRIVATE KEY) formats
HTTP Caching
Pulpogato provides CachingExchangeFilterFunction to cache HTTP responses based on standard HTTP caching headers (ETag, Last-Modified, Cache-Control).
This can significantly reduce API calls and improve performance.
Basic Caching Setup
var cachingFilter = CachingExchangeFilterFunction.builder()
.cache(cache) (1)
.build();
var cachingClient = webClient
.mutate()
.filter(cachingFilter) (2)
.build();
| 1 | This is a Spring Cache (ConcurrentMapCache, Caffeine, Redis, etc.) |
| 2 | You can add multiple filters here. This filter configures caching. |
The filter handles:
-
ETag / If-None-Match: Stores the ETag and sends conditional requests
-
Last-Modified / If-Modified-Since: Stores the last modified date and sends conditional requests
-
Cache-Control max-age: Returns cached responses directly while fresh
The response includes an X-Pulpogato-Cache header indicating the cache status:
-
HIT- Response served from cache without server request -
MISS- Response fetched from server and cached -
REVALIDATED- Server returned 304, cached response body returned -
SKIP- Response too large to cache (still returned to caller)
Advanced Caching Options
var cachingFilter = CachingExchangeFilterFunction.builder()
.cache(cache)
.cacheKeyMapper(request -> request.url().toASCIIString()) (1)
.maxCacheableSize(4 * 1024 * 1024) (2)
.alwaysRevalidate(true) (3)
.build();
var cachingClient = webClient.mutate().filter(cachingFilter).build();
| 1 | Custom function to generate cache keys from requests |
| 2 | Maximum response size to cache (default: 2MB) |
| 3 | Always send conditional requests even if cache is fresh. This is helpful when GitHub’s cache directive is too long for your usecase. |
Rate Limit Metrics
Pulpogato provides MetricsExchangeFunction to capture GitHub API rate limit information as Micrometer metrics.
This helps monitor your API usage and avoid hitting rate limits.
Basic Metrics Setup
var metricsFilter = MetricsExchangeFunction.builder()
.registry(meterRegistry) (1)
.build();
var metricsClient = webClient.mutate().filter(metricsFilter).build();
| 1 | This is a Micrometer MeterRegistry (SimpleMeterRegistry, PrometheusMeterRegistry, etc.) It could be injected from Spring. |
The filter extracts these headers from GitHub responses and creates gauges:
-
github.api.rateLimit.limit- Maximum requests allowed -
github.api.rateLimit.remaining- Requests remaining in current window -
github.api.rateLimit.used- Requests used in current window -
github.api.rateLimit.secondsToReset- Seconds until rate limit resets
All metrics are tagged with the resource (e.g., "core", "search", "graphql").
Advanced Metrics Options
var metricsFilter = MetricsExchangeFunction.builder()
.registry(meterRegistry)
.prefix("myapp.github.rateLimit") (1)
.defaultTags(List.of(Tag.of("env", "production"))) (2)
.build();
var metricsClient = webClient.mutate().filter(metricsFilter).build();
| 1 | Custom metric name prefix |
| 2 | Additional tags to add to all metrics |