Part 51: REST Integration Project — Connecting Salesforce to GitHub
Welcome back to the Salesforce series. In the previous posts we covered the theory behind REST and SOAP integrations, callout patterns, and the HTTP classes Salesforce provides. Now it is time to put all of that into practice by building a real integration.
In this post we are going to connect Salesforce to the GitHub REST API. By the end you will have a fully working integration that can list repositories, fetch repository details, list issues, create issues, and list commits — all from Apex. We will set up authentication with Named Credentials, test endpoints with Postman, build JSON wrapper classes, create a clean service layer, and write a comprehensive test class that achieves well above 90% coverage.
This is a hands-on, code-heavy post. If you have been following the series, you already have all the prerequisite knowledge. Let us build something real.
Why GitHub as an Integration Target?
GitHub is an excellent choice for your first REST integration project for several reasons:
- Well-documented API — The GitHub REST API has clear documentation, consistent patterns, and predictable response shapes.
- Free access — You can authenticate with a Personal Access Token at no cost.
- Real-world relevance — Many Salesforce teams use GitHub for version control, and connecting the two platforms has genuine business value. Imagine automatically creating a GitHub issue when a Salesforce case meets certain criteria, or displaying a repository’s recent commits on a custom Lightning page.
- Multiple HTTP methods — We will use both GET and POST requests, giving us practice with different callout patterns.
Setting Up Authentication: Named Credentials
Before we write any Apex, we need to configure Salesforce so it can authenticate against the GitHub API. In modern Salesforce (post-Spring ‘22), authentication is handled through a combination of External Credentials and Named Credentials.
Step 1: Generate a GitHub Personal Access Token
- Log in to your GitHub account.
- Navigate to Settings > Developer settings > Personal access tokens > Tokens (classic).
- Click Generate new token (classic).
- Give the token a descriptive name like
Salesforce Integration. - Select the scopes you need. For this project, select:
repo(full control of private repositories)read:user(read access to user profile data)
- Click Generate token.
- Copy the token immediately. GitHub will not show it again.
Step 2: Create a Remote Site Setting
Even though we are using Named Credentials, it is good practice to have a Remote Site Setting in place. Some orgs require it depending on their security configuration.
- In Salesforce Setup, navigate to Security > Remote Site Settings.
- Click New Remote Site.
- Fill in the fields:
- Remote Site Name:
GitHub_API - Remote Site URL:
https://api.github.com - Active: Checked
- Remote Site Name:
- Click Save.
Step 3: Create an External Credential
External Credentials store the actual authentication details — the token, username, or OAuth configuration.
- In Setup, navigate to Security > Named Credentials and select the External Credentials tab.
- Click New.
- Fill in the fields:
- Label:
GitHub External Credential - Name:
GitHub_External_Credential - Authentication Protocol: Custom
- Label:
- Click Save.
- After saving, scroll down to the Principals section and click New.
- Fill in:
- Parameter Name:
GitHub_Principal - Sequence Number: 1
- Identity Type: Named Principal
- Parameter Name:
- Click Save.
- Now add the authentication header. Under the principal you just created, go to Authentication Parameters and click New.
- Name:
Authorization - Value:
token YOUR_PERSONAL_ACCESS_TOKEN
- Name:
- Click Save.
Step 4: Create the Named Credential
Named Credentials are what your Apex code references. They abstract away the authentication details so your code never contains hardcoded tokens or URLs.
- Go back to the Named Credentials tab (not External Credentials).
- Click New.
- Fill in the fields:
- Label:
GitHub - Name:
GitHub - URL:
https://api.github.com - External Credential:
GitHub External Credential - Generate Authorization Header: Unchecked (we are handling this through the External Credential)
- Label:
- Click Save.
Step 5: Assign the Permission Set
Salesforce requires that users who execute callouts through Named Credentials have the appropriate External Credential Principal assigned.
- Create a new Permission Set called
GitHub Integration User. - Under External Credential Principal Access, add
GitHub_External_Credential - GitHub_Principal. - Assign this Permission Set to any user who will run the integration (including the integration user and yourself for testing).
Your authentication setup is complete. Apex code can now reference callout:GitHub as the endpoint prefix, and Salesforce will handle attaching the correct authorization header automatically.
Testing with Postman
Before writing any Apex, it is smart to verify the API endpoints and response formats using Postman. This saves you from debugging authentication issues and JSON parsing problems at the same time.
Setting Up Postman
- Open Postman and create a new collection called
GitHub API. - Set up an Authorization header at the collection level:
- Type: Bearer Token
- Token: Your Personal Access Token
- Alternatively, add a header manually:
- Key:
Authorization - Value:
token YOUR_PERSONAL_ACCESS_TOKEN
- Key:
- Add a second header:
- Key:
Accept - Value:
application/vnd.github.v3+json
- Key:
Testing Key Endpoints
List Repositories for the Authenticated User
GET https://api.github.com/user/repos?per_page=5
A successful response returns an array of repository objects. Note the key fields we will need: id, name, full_name, description, html_url, language, private, created_at, and updated_at.
Get a Specific Repository
GET https://api.github.com/repos/{owner}/{repo}
Replace {owner} with the GitHub username and {repo} with the repository name. This returns a single repository object with additional detail fields like stargazers_count, forks_count, and open_issues_count.
List Issues for a Repository
GET https://api.github.com/repos/{owner}/{repo}/issues?state=open
Returns an array of issue objects with fields like id, number, title, body, state, html_url, and created_at.
Create an Issue
POST https://api.github.com/repos/{owner}/{repo}/issues
With a JSON body:
{
"title": "Test issue from Postman",
"body": "This is a test issue created to verify API access.",
"labels": ["bug"]
}
A successful creation returns a 201 Created status with the new issue object.
List Commits for a Repository
GET https://api.github.com/repos/{owner}/{repo}/commits?per_page=10
Returns an array of commit objects. Each commit has a nested structure: the sha, a commit object containing message and author (with name, email, date), and an html_url.
What to Verify
For each endpoint, confirm:
- The response status code is what you expect (200 for GET, 201 for POST).
- The JSON structure matches what you will model in your wrapper classes.
- Your token has the necessary permissions for the scopes you need.
- Rate limiting headers (
X-RateLimit-Remaining) show you have capacity.
Once all endpoints return clean responses in Postman, you can confidently move into Apex knowing the API side is solid.
Creating the JSON Wrapper Classes
Wrapper classes are plain Apex classes that mirror the structure of JSON objects. When you deserialize a GitHub API response, Salesforce maps JSON keys to class properties. Proper wrapper classes make your code type-safe and easy to work with.
We will create a single outer class that contains all our wrapper types as inner classes. This keeps the codebase organized and makes it clear that these classes belong together.
The Complete Wrapper Class
/**
* GitHubModels
* Wrapper classes for GitHub REST API responses.
* Each inner class maps to a JSON response shape from a specific endpoint.
*/
public class GitHubModels {
/**
* Represents a GitHub repository.
* Used by: GET /user/repos, GET /repos/{owner}/{repo}
*/
public class Repository {
public Integer id;
public String name;
public String full_name;
public String description;
public String html_url;
public String language;
public Boolean isPrivate;
public String created_at;
public String updated_at;
public Integer stargazers_count;
public Integer forks_count;
public Integer open_issues_count;
// GitHub uses "private" as a field name, which is a reserved word in Apex.
// We handle this during deserialization.
}
/**
* Represents a GitHub issue.
* Used by: GET /repos/{owner}/{repo}/issues, POST /repos/{owner}/{repo}/issues
*/
public class Issue {
public Integer id;
public Integer number_x; // "number" is reserved in some contexts
public String title;
public String body;
public String state;
public String html_url;
public String created_at;
public String updated_at;
public User user;
public List<Label> labels;
}
/**
* Represents the request body for creating a new issue.
*/
public class IssueCreateRequest {
public String title;
public String body;
public List<String> labels;
public IssueCreateRequest(String title, String body, List<String> labels) {
this.title = title;
this.body = body;
this.labels = labels;
}
}
/**
* Represents a GitHub commit.
* Used by: GET /repos/{owner}/{repo}/commits
*/
public class Commit {
public String sha;
public CommitDetail commit_x; // renamed to avoid confusion
public String html_url;
public User author;
}
/**
* Nested commit detail containing message and author info.
*/
public class CommitDetail {
public String message;
public CommitAuthor author;
}
/**
* Author information nested inside a commit.
*/
public class CommitAuthor {
public String name;
public String email;
public String date_x; // "date" can cause issues
}
/**
* Represents a GitHub user (simplified).
*/
public class User {
public Integer id;
public String login;
public String avatar_url;
public String html_url;
}
/**
* Represents a GitHub label.
*/
public class Label {
public Integer id;
public String name;
public String color;
public String description;
}
/**
* Generic error response from the GitHub API.
*/
public class GitHubError {
public String message;
public String documentation_url;
}
/**
* Custom exception for GitHub API errors.
*/
public class GitHubApiException extends Exception {}
/**
* Deserializes a repository JSON string, handling the reserved word "private".
*/
public static Repository deserializeRepository(String jsonString) {
// Replace the reserved keyword before deserializing
String sanitized = jsonString.replace('"private":', '"isPrivate":');
return (Repository) JSON.deserialize(sanitized, Repository.class);
}
/**
* Deserializes a list of repository JSON strings.
*/
public static List<Repository> deserializeRepositoryList(String jsonString) {
String sanitized = jsonString.replace('"private":', '"isPrivate":');
return (List<Repository>) JSON.deserialize(sanitized, List<Repository>.class);
}
/**
* Deserializes a commit JSON string, handling nested field name conflicts.
*/
public static List<Commit> deserializeCommitList(String jsonString) {
String sanitized = jsonString.replace('"commit":', '"commit_x":');
sanitized = sanitized.replace('"date":', '"date_x":');
return (List<Commit>) JSON.deserialize(sanitized, List<Commit>.class);
}
/**
* Deserializes an issue JSON string, handling the "number" field.
*/
public static List<Issue> deserializeIssueList(String jsonString) {
String sanitized = jsonString.replace('"number":', '"number_x":');
return (List<Issue>) JSON.deserialize(sanitized, List<Issue>.class);
}
/**
* Deserializes a single issue JSON string.
*/
public static Issue deserializeIssue(String jsonString) {
String sanitized = jsonString.replace('"number":', '"number_x":');
return (Issue) JSON.deserialize(sanitized, Issue.class);
}
}
Why This Structure?
There are a few design decisions worth calling out:
- Inner classes inside a single outer class. This avoids polluting the global namespace with dozens of small classes. Everything GitHub-related lives under
GitHubModels. - Reserved word handling. Apex reserves words like
private,number, anddate. The GitHub API uses all three as field names. We rename the Apex properties and use string replacement before deserialization. An alternative is to useJSON.deserializeUntyped()and work with maps, but typed deserialization gives you compile-time safety. - Separate request and response classes.
IssueCreateRequestis only used for outbound payloads. Keeping it separate fromIssue(which represents an inbound response) makes the intent clear. - Static deserialization methods. Centralizing the reserved-word replacement logic in static methods means the service class does not need to know about these quirks. It just calls
GitHubModels.deserializeRepositoryList(responseBody)and gets back typed objects.
Creating the Integration Service Class
The service class is where all the HTTP logic lives. It follows the separation of concerns principle: the wrapper classes handle data structure, the service class handles communication, and any controller or invocable action handles orchestration.
The Complete Service Class
/**
* GitHubService
* Handles all HTTP communication with the GitHub REST API.
* Uses the "GitHub" Named Credential for authentication.
*/
public with sharing class GitHubService {
private static final String NAMED_CREDENTIAL = 'callout:GitHub';
private static final String ACCEPT_HEADER = 'application/vnd.github.v3+json';
private static final String CONTENT_TYPE = 'application/json';
private static final Integer DEFAULT_PER_PAGE = 30;
private static final Integer DEFAULT_TIMEOUT = 30000; // 30 seconds
/**
* Lists repositories for the authenticated user.
* @param perPage Number of repos per page (max 100).
* @return List of Repository wrapper objects.
*/
public static List<GitHubModels.Repository> listMyRepositories(Integer perPage) {
if (perPage == null || perPage < 1) {
perPage = DEFAULT_PER_PAGE;
}
if (perPage > 100) {
perPage = 100;
}
String endpoint = NAMED_CREDENTIAL + '/user/repos?per_page=' + perPage;
HttpResponse response = executeGet(endpoint);
if (response.getStatusCode() == 200) {
return GitHubModels.deserializeRepositoryList(response.getBody());
} else {
handleError(response);
return new List<GitHubModels.Repository>();
}
}
/**
* Gets details for a specific repository.
* @param owner The repository owner (GitHub username or org).
* @param repo The repository name.
* @return A single Repository wrapper object.
*/
public static GitHubModels.Repository getRepository(String owner, String repo) {
validateRequiredParam(owner, 'owner');
validateRequiredParam(repo, 'repo');
String endpoint = NAMED_CREDENTIAL + '/repos/'
+ EncodingUtil.urlEncode(owner, 'UTF-8') + '/'
+ EncodingUtil.urlEncode(repo, 'UTF-8');
HttpResponse response = executeGet(endpoint);
if (response.getStatusCode() == 200) {
return GitHubModels.deserializeRepository(response.getBody());
} else {
handleError(response);
return null;
}
}
/**
* Lists issues for a repository.
* @param owner The repository owner.
* @param repo The repository name.
* @param state Filter by state: open, closed, or all.
* @return List of Issue wrapper objects.
*/
public static List<GitHubModels.Issue> listIssues(
String owner, String repo, String state
) {
validateRequiredParam(owner, 'owner');
validateRequiredParam(repo, 'repo');
if (String.isBlank(state)) {
state = 'open';
}
String endpoint = NAMED_CREDENTIAL + '/repos/'
+ EncodingUtil.urlEncode(owner, 'UTF-8') + '/'
+ EncodingUtil.urlEncode(repo, 'UTF-8')
+ '/issues?state=' + EncodingUtil.urlEncode(state, 'UTF-8');
HttpResponse response = executeGet(endpoint);
if (response.getStatusCode() == 200) {
return GitHubModels.deserializeIssueList(response.getBody());
} else {
handleError(response);
return new List<GitHubModels.Issue>();
}
}
/**
* Creates a new issue in a repository.
* @param owner The repository owner.
* @param repo The repository name.
* @param title The issue title.
* @param body The issue body/description.
* @param labels List of label names to apply.
* @return The created Issue wrapper object.
*/
public static GitHubModels.Issue createIssue(
String owner, String repo, String title, String body, List<String> labels
) {
validateRequiredParam(owner, 'owner');
validateRequiredParam(repo, 'repo');
validateRequiredParam(title, 'title');
if (labels == null) {
labels = new List<String>();
}
String endpoint = NAMED_CREDENTIAL + '/repos/'
+ EncodingUtil.urlEncode(owner, 'UTF-8') + '/'
+ EncodingUtil.urlEncode(repo, 'UTF-8')
+ '/issues';
GitHubModels.IssueCreateRequest requestBody =
new GitHubModels.IssueCreateRequest(title, body, labels);
HttpResponse response = executePost(endpoint, JSON.serialize(requestBody));
if (response.getStatusCode() == 201) {
return GitHubModels.deserializeIssue(response.getBody());
} else {
handleError(response);
return null;
}
}
/**
* Lists commits for a repository.
* @param owner The repository owner.
* @param repo The repository name.
* @param perPage Number of commits to return.
* @return List of Commit wrapper objects.
*/
public static List<GitHubModels.Commit> listCommits(
String owner, String repo, Integer perPage
) {
validateRequiredParam(owner, 'owner');
validateRequiredParam(repo, 'repo');
if (perPage == null || perPage < 1) {
perPage = DEFAULT_PER_PAGE;
}
if (perPage > 100) {
perPage = 100;
}
String endpoint = NAMED_CREDENTIAL + '/repos/'
+ EncodingUtil.urlEncode(owner, 'UTF-8') + '/'
+ EncodingUtil.urlEncode(repo, 'UTF-8')
+ '/commits?per_page=' + perPage;
HttpResponse response = executeGet(endpoint);
if (response.getStatusCode() == 200) {
return GitHubModels.deserializeCommitList(response.getBody());
} else {
handleError(response);
return new List<GitHubModels.Commit>();
}
}
// -------------------------------------------------------
// Private helper methods
// -------------------------------------------------------
/**
* Executes a GET request with standard headers.
*/
private static HttpResponse executeGet(String endpoint) {
HttpRequest req = new HttpRequest();
req.setEndpoint(endpoint);
req.setMethod('GET');
req.setHeader('Accept', ACCEPT_HEADER);
req.setTimeout(DEFAULT_TIMEOUT);
Http http = new Http();
return http.send(req);
}
/**
* Executes a POST request with standard headers and a JSON body.
*/
private static HttpResponse executePost(String endpoint, String jsonBody) {
HttpRequest req = new HttpRequest();
req.setEndpoint(endpoint);
req.setMethod('POST');
req.setHeader('Accept', ACCEPT_HEADER);
req.setHeader('Content-Type', CONTENT_TYPE);
req.setTimeout(DEFAULT_TIMEOUT);
req.setBody(jsonBody);
Http http = new Http();
return http.send(req);
}
/**
* Handles error responses from the GitHub API.
* Parses the error body and throws a typed exception.
*/
private static void handleError(HttpResponse response) {
String errorMessage;
try {
GitHubModels.GitHubError errorBody =
(GitHubModels.GitHubError) JSON.deserialize(
response.getBody(), GitHubModels.GitHubError.class
);
errorMessage = 'GitHub API Error (' + response.getStatusCode() + '): '
+ errorBody.message;
} catch (Exception e) {
errorMessage = 'GitHub API Error (' + response.getStatusCode() + '): '
+ response.getBody();
}
System.debug(LoggingLevel.ERROR, errorMessage);
throw new GitHubModels.GitHubApiException(errorMessage);
}
/**
* Validates that a required parameter is not blank.
*/
private static void validateRequiredParam(String value, String paramName) {
if (String.isBlank(value)) {
throw new IllegalArgumentException(
'Required parameter "' + paramName + '" cannot be blank.'
);
}
}
}
Design Decisions in the Service Class
Named Credential as a constant. The callout:GitHub prefix is defined once. If the Named Credential name changes, you update one line.
URL encoding for path parameters. Owner and repo names could theoretically contain special characters. Using EncodingUtil.urlEncode() makes the code defensive.
Input validation. Every public method validates its required parameters before making a callout. This catches errors early and produces clear messages instead of cryptic HTTP 404s from GitHub.
Centralized HTTP execution. The executeGet and executePost methods handle request construction. If you later need to add a custom header (like a correlation ID for logging), you add it in one place.
Structured error handling. The handleError method attempts to parse the GitHub error response body. If parsing fails (perhaps the response is not JSON), it falls back to the raw body. Either way, it throws a custom GitHubApiException so callers can catch a specific type.
Timeout configuration. The 30-second timeout is generous but not unlimited. In production, you might lower this or make it configurable.
Exposing the Service with an Invocable Action
To make this integration usable from Flows, we can create an invocable Apex action. This bridges the declarative and programmatic worlds — an admin can trigger the GitHub integration from a Flow without writing any code.
/**
* GitHubInvocableActions
* Exposes GitHubService methods as invocable actions for Flow.
*/
public with sharing class GitHubInvocableActions {
/**
* Input class for the Create Issue invocable action.
*/
public class CreateIssueInput {
@InvocableVariable(label='Repository Owner' required=true)
public String owner;
@InvocableVariable(label='Repository Name' required=true)
public String repo;
@InvocableVariable(label='Issue Title' required=true)
public String title;
@InvocableVariable(label='Issue Body')
public String body;
@InvocableVariable(label='Labels (comma-separated)')
public String labelsCSV;
}
/**
* Output class for the Create Issue invocable action.
*/
public class CreateIssueOutput {
@InvocableVariable(label='Issue Number')
public Integer issueNumber;
@InvocableVariable(label='Issue URL')
public String issueUrl;
@InvocableVariable(label='Success')
public Boolean success;
@InvocableVariable(label='Error Message')
public String errorMessage;
}
/**
* Creates a GitHub issue. Callable from Flow.
*/
@InvocableMethod(
label='Create GitHub Issue'
description='Creates a new issue in a GitHub repository.'
category='GitHub Integration'
)
public static List<CreateIssueOutput> createIssue(
List<CreateIssueInput> inputs
) {
List<CreateIssueOutput> outputs = new List<CreateIssueOutput>();
for (CreateIssueInput input : inputs) {
CreateIssueOutput output = new CreateIssueOutput();
try {
List<String> labels = new List<String>();
if (String.isNotBlank(input.labelsCSV)) {
for (String label : input.labelsCSV.split(',')) {
labels.add(label.trim());
}
}
GitHubModels.Issue createdIssue = GitHubService.createIssue(
input.owner,
input.repo,
input.title,
input.body,
labels
);
output.issueNumber = createdIssue.number_x;
output.issueUrl = createdIssue.html_url;
output.success = true;
} catch (Exception e) {
output.success = false;
output.errorMessage = e.getMessage();
}
outputs.add(output);
}
return outputs;
}
}
Now an admin can drag a “Create GitHub Issue” action into any Flow, supply the owner, repo name, title, body, and labels, and have the integration fire without touching Apex.
Writing the Test Class
Testing callout code in Salesforce requires mock responses. You cannot make real HTTP callouts in a test context — Salesforce blocks them. Instead, you implement the HttpCalloutMock interface to return predefined responses, and the framework injects those responses when your code calls Http.send().
Our test strategy needs to cover:
- Every public method in
GitHubService. - Both success and error scenarios.
- The invocable action.
- Edge cases like blank parameters and boundary values.
- The deserialization logic in
GitHubModels.
The Mock Class
We will use a single, flexible mock that can return different responses based on the endpoint being called.
/**
* GitHubServiceTest
* Comprehensive test class for GitHubService, GitHubModels, and
* GitHubInvocableActions.
*/
@IsTest
private class GitHubServiceTest {
// -------------------------------------------------------
// Multi-endpoint mock implementation
// -------------------------------------------------------
/**
* A flexible mock that inspects the request endpoint and method
* to return the appropriate mock response.
*/
private class GitHubMock implements HttpCalloutMock {
private Integer statusCode;
private String responseBody;
private Boolean useEndpointRouting;
/**
* Constructor for a simple mock that always returns the same response.
*/
GitHubMock(Integer statusCode, String responseBody) {
this.statusCode = statusCode;
this.responseBody = responseBody;
this.useEndpointRouting = false;
}
/**
* Constructor for an endpoint-routing mock.
*/
GitHubMock() {
this.useEndpointRouting = true;
}
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
if (useEndpointRouting) {
String endpoint = req.getEndpoint();
String method = req.getMethod();
if (endpoint.contains('/user/repos')) {
res.setStatusCode(200);
res.setBody(getMockRepositoryListJson());
} else if (endpoint.contains('/commits')) {
res.setStatusCode(200);
res.setBody(getMockCommitListJson());
} else if (endpoint.contains('/issues') && method == 'POST') {
res.setStatusCode(201);
res.setBody(getMockCreatedIssueJson());
} else if (endpoint.contains('/issues')) {
res.setStatusCode(200);
res.setBody(getMockIssueListJson());
} else if (endpoint.contains('/repos/')) {
res.setStatusCode(200);
res.setBody(getMockSingleRepoJson());
} else {
res.setStatusCode(404);
res.setBody('{"message":"Not Found"}');
}
} else {
res.setStatusCode(statusCode);
res.setBody(responseBody);
}
return res;
}
}
// -------------------------------------------------------
// Mock JSON response builders
// -------------------------------------------------------
private static String getMockRepositoryListJson() {
return '[' +
'{' +
'"id": 123456,' +
'"name": "salesforce-integration",' +
'"full_name": "testuser/salesforce-integration",' +
'"description": "A sample Salesforce integration project",' +
'"html_url": "https://github.com/testuser/salesforce-integration",' +
'"language": "Apex",' +
'"private": false,' +
'"created_at": "2026-01-15T10:30:00Z",' +
'"updated_at": "2026-04-20T14:00:00Z",' +
'"stargazers_count": 42,' +
'"forks_count": 8,' +
'"open_issues_count": 3' +
'},' +
'{' +
'"id": 789012,' +
'"name": "lwc-recipes",' +
'"full_name": "testuser/lwc-recipes",' +
'"description": "Lightning Web Components recipes",' +
'"html_url": "https://github.com/testuser/lwc-recipes",' +
'"language": "JavaScript",' +
'"private": true,' +
'"created_at": "2025-11-01T08:00:00Z",' +
'"updated_at": "2026-03-10T16:45:00Z",' +
'"stargazers_count": 15,' +
'"forks_count": 2,' +
'"open_issues_count": 1' +
'}' +
']';
}
private static String getMockSingleRepoJson() {
return '{' +
'"id": 123456,' +
'"name": "salesforce-integration",' +
'"full_name": "testuser/salesforce-integration",' +
'"description": "A sample Salesforce integration project",' +
'"html_url": "https://github.com/testuser/salesforce-integration",' +
'"language": "Apex",' +
'"private": false,' +
'"created_at": "2026-01-15T10:30:00Z",' +
'"updated_at": "2026-04-20T14:00:00Z",' +
'"stargazers_count": 42,' +
'"forks_count": 8,' +
'"open_issues_count": 3' +
'}';
}
private static String getMockIssueListJson() {
return '[' +
'{' +
'"id": 1,' +
'"number": 42,' +
'"title": "Fix null pointer in batch job",' +
'"body": "The nightly batch throws NPE when Account.Name is null.",' +
'"state": "open",' +
'"html_url": "https://github.com/testuser/salesforce-integration/issues/42",' +
'"created_at": "2026-04-18T09:00:00Z",' +
'"updated_at": "2026-04-18T09:00:00Z",' +
'"user": {' +
'"id": 100,' +
'"login": "testuser",' +
'"avatar_url": "https://avatars.githubusercontent.com/u/100",' +
'"html_url": "https://github.com/testuser"' +
'},' +
'"labels": [' +
'{' +
'"id": 10,' +
'"name": "bug",' +
'"color": "d73a4a",' +
'"description": "Something is not working"' +
'}' +
']' +
'}' +
']';
}
private static String getMockCreatedIssueJson() {
return '{' +
'"id": 2,' +
'"number": 43,' +
'"title": "New issue from Salesforce",' +
'"body": "Created via Apex integration.",' +
'"state": "open",' +
'"html_url": "https://github.com/testuser/salesforce-integration/issues/43",' +
'"created_at": "2026-04-29T12:00:00Z",' +
'"updated_at": "2026-04-29T12:00:00Z",' +
'"user": {' +
'"id": 100,' +
'"login": "testuser",' +
'"avatar_url": "https://avatars.githubusercontent.com/u/100",' +
'"html_url": "https://github.com/testuser"' +
'},' +
'"labels": []' +
'}';
}
private static String getMockCommitListJson() {
return '[' +
'{' +
'"sha": "abc123def456",' +
'"commit": {' +
'"message": "Fix: handle null Account names in batch",' +
'"author": {' +
'"name": "Test User",' +
'"email": "test@example.com",' +
'"date": "2026-04-28T15:30:00Z"' +
'}' +
'},' +
'"html_url": "https://github.com/testuser/salesforce-integration/commit/abc123",' +
'"author": {' +
'"id": 100,' +
'"login": "testuser",' +
'"avatar_url": "https://avatars.githubusercontent.com/u/100",' +
'"html_url": "https://github.com/testuser"' +
'}' +
'},' +
'{' +
'"sha": "789xyz000111",' +
'"commit": {' +
'"message": "Add unit tests for AccountService",' +
'"author": {' +
'"name": "Test User",' +
'"email": "test@example.com",' +
'"date": "2026-04-27T10:00:00Z"' +
'}' +
'},' +
'"html_url": "https://github.com/testuser/salesforce-integration/commit/789xyz",' +
'"author": {' +
'"id": 100,' +
'"login": "testuser",' +
'"avatar_url": "https://avatars.githubusercontent.com/u/100",' +
'"html_url": "https://github.com/testuser"' +
'}' +
'}' +
']';
}
private static String getMockErrorJson() {
return '{"message": "Not Found", "documentation_url": "https://docs.github.com/rest"}';
}
// -------------------------------------------------------
// Tests: GitHubService.listMyRepositories
// -------------------------------------------------------
@IsTest
static void testListMyRepositories_Success() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
List<GitHubModels.Repository> repos =
GitHubService.listMyRepositories(5);
Test.stopTest();
System.assertEquals(2, repos.size(), 'Should return 2 repositories');
System.assertEquals('salesforce-integration', repos[0].name);
System.assertEquals('testuser/salesforce-integration', repos[0].full_name);
System.assertEquals(false, repos[0].isPrivate);
System.assertEquals(42, repos[0].stargazers_count);
System.assertEquals('lwc-recipes', repos[1].name);
System.assertEquals(true, repos[1].isPrivate);
}
@IsTest
static void testListMyRepositories_DefaultPerPage() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
List<GitHubModels.Repository> repos =
GitHubService.listMyRepositories(null);
Test.stopTest();
System.assertNotEquals(null, repos, 'Should return a non-null list');
}
@IsTest
static void testListMyRepositories_ExceedsMaxPerPage() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
List<GitHubModels.Repository> repos =
GitHubService.listMyRepositories(500);
Test.stopTest();
System.assertNotEquals(null, repos, 'Should cap per_page at 100 and succeed');
}
@IsTest
static void testListMyRepositories_Error() {
Test.setMock(
HttpCalloutMock.class,
new GitHubMock(401, '{"message":"Bad credentials"}')
);
Test.startTest();
try {
GitHubService.listMyRepositories(5);
System.assert(false, 'Should have thrown GitHubApiException');
} catch (GitHubModels.GitHubApiException e) {
System.assert(
e.getMessage().contains('Bad credentials'),
'Error message should contain API error'
);
}
Test.stopTest();
}
// -------------------------------------------------------
// Tests: GitHubService.getRepository
// -------------------------------------------------------
@IsTest
static void testGetRepository_Success() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
GitHubModels.Repository repo =
GitHubService.getRepository('testuser', 'salesforce-integration');
Test.stopTest();
System.assertNotEquals(null, repo, 'Should return a repository');
System.assertEquals(123456, repo.id);
System.assertEquals('salesforce-integration', repo.name);
System.assertEquals('Apex', repo.language);
}
@IsTest
static void testGetRepository_BlankOwner() {
Test.startTest();
try {
GitHubService.getRepository('', 'some-repo');
System.assert(false, 'Should have thrown IllegalArgumentException');
} catch (IllegalArgumentException e) {
System.assert(
e.getMessage().contains('owner'),
'Error should reference the owner parameter'
);
}
Test.stopTest();
}
@IsTest
static void testGetRepository_BlankRepo() {
Test.startTest();
try {
GitHubService.getRepository('testuser', '');
System.assert(false, 'Should have thrown IllegalArgumentException');
} catch (IllegalArgumentException e) {
System.assert(
e.getMessage().contains('repo'),
'Error should reference the repo parameter'
);
}
Test.stopTest();
}
@IsTest
static void testGetRepository_NotFound() {
Test.setMock(
HttpCalloutMock.class,
new GitHubMock(404, getMockErrorJson())
);
Test.startTest();
try {
GitHubService.getRepository('testuser', 'nonexistent');
System.assert(false, 'Should have thrown GitHubApiException');
} catch (GitHubModels.GitHubApiException e) {
System.assert(
e.getMessage().contains('404'),
'Error should contain status code'
);
}
Test.stopTest();
}
// -------------------------------------------------------
// Tests: GitHubService.listIssues
// -------------------------------------------------------
@IsTest
static void testListIssues_Success() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
List<GitHubModels.Issue> issues =
GitHubService.listIssues('testuser', 'salesforce-integration', 'open');
Test.stopTest();
System.assertEquals(1, issues.size(), 'Should return 1 issue');
System.assertEquals(42, issues[0].number_x);
System.assertEquals('Fix null pointer in batch job', issues[0].title);
System.assertEquals('open', issues[0].state);
System.assertNotEquals(null, issues[0].user);
System.assertEquals('testuser', issues[0].user.login);
System.assertEquals(1, issues[0].labels.size());
System.assertEquals('bug', issues[0].labels[0].name);
}
@IsTest
static void testListIssues_DefaultState() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
List<GitHubModels.Issue> issues =
GitHubService.listIssues('testuser', 'salesforce-integration', null);
Test.stopTest();
System.assertNotEquals(null, issues, 'Should default to open state and succeed');
}
@IsTest
static void testListIssues_ServerError() {
Test.setMock(
HttpCalloutMock.class,
new GitHubMock(500, '{"message":"Internal Server Error"}')
);
Test.startTest();
try {
GitHubService.listIssues('testuser', 'salesforce-integration', 'open');
System.assert(false, 'Should have thrown GitHubApiException');
} catch (GitHubModels.GitHubApiException e) {
System.assert(
e.getMessage().contains('500'),
'Error should contain status code'
);
}
Test.stopTest();
}
// -------------------------------------------------------
// Tests: GitHubService.createIssue
// -------------------------------------------------------
@IsTest
static void testCreateIssue_Success() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
GitHubModels.Issue issue = GitHubService.createIssue(
'testuser',
'salesforce-integration',
'New issue from Salesforce',
'Created via Apex integration.',
new List<String>{ 'enhancement' }
);
Test.stopTest();
System.assertNotEquals(null, issue, 'Should return the created issue');
System.assertEquals(43, issue.number_x);
System.assertEquals('New issue from Salesforce', issue.title);
System.assertEquals('open', issue.state);
}
@IsTest
static void testCreateIssue_NullLabels() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
GitHubModels.Issue issue = GitHubService.createIssue(
'testuser',
'salesforce-integration',
'Issue without labels',
'Testing null labels parameter.',
null
);
Test.stopTest();
System.assertNotEquals(null, issue, 'Should handle null labels gracefully');
}
@IsTest
static void testCreateIssue_BlankTitle() {
Test.startTest();
try {
GitHubService.createIssue(
'testuser', 'salesforce-integration', '', 'Body', null
);
System.assert(false, 'Should have thrown IllegalArgumentException');
} catch (IllegalArgumentException e) {
System.assert(
e.getMessage().contains('title'),
'Error should reference the title parameter'
);
}
Test.stopTest();
}
@IsTest
static void testCreateIssue_Forbidden() {
Test.setMock(
HttpCalloutMock.class,
new GitHubMock(403, '{"message":"Resource not accessible by integration"}')
);
Test.startTest();
try {
GitHubService.createIssue(
'testuser', 'private-repo', 'Title', 'Body', null
);
System.assert(false, 'Should have thrown GitHubApiException');
} catch (GitHubModels.GitHubApiException e) {
System.assert(
e.getMessage().contains('403'),
'Error should contain status code'
);
}
Test.stopTest();
}
// -------------------------------------------------------
// Tests: GitHubService.listCommits
// -------------------------------------------------------
@IsTest
static void testListCommits_Success() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
List<GitHubModels.Commit> commits =
GitHubService.listCommits('testuser', 'salesforce-integration', 10);
Test.stopTest();
System.assertEquals(2, commits.size(), 'Should return 2 commits');
System.assertEquals('abc123def456', commits[0].sha);
System.assertNotEquals(null, commits[0].commit_x);
System.assertEquals(
'Fix: handle null Account names in batch',
commits[0].commit_x.message
);
System.assertEquals('Test User', commits[0].commit_x.author.name);
System.assertEquals('test@example.com', commits[0].commit_x.author.email);
}
@IsTest
static void testListCommits_DefaultPerPage() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
Test.startTest();
List<GitHubModels.Commit> commits =
GitHubService.listCommits('testuser', 'salesforce-integration', null);
Test.stopTest();
System.assertNotEquals(null, commits, 'Should default per_page and succeed');
}
@IsTest
static void testListCommits_Error() {
Test.setMock(
HttpCalloutMock.class,
new GitHubMock(404, getMockErrorJson())
);
Test.startTest();
try {
GitHubService.listCommits('testuser', 'nonexistent', 10);
System.assert(false, 'Should have thrown GitHubApiException');
} catch (GitHubModels.GitHubApiException e) {
System.assert(
e.getMessage().contains('Not Found'),
'Error should contain API message'
);
}
Test.stopTest();
}
// -------------------------------------------------------
// Tests: GitHubInvocableActions
// -------------------------------------------------------
@IsTest
static void testInvocableCreateIssue_Success() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
GitHubInvocableActions.CreateIssueInput input =
new GitHubInvocableActions.CreateIssueInput();
input.owner = 'testuser';
input.repo = 'salesforce-integration';
input.title = 'Flow-created issue';
input.body = 'This issue was created from a Salesforce Flow.';
input.labelsCSV = 'bug, enhancement';
Test.startTest();
List<GitHubInvocableActions.CreateIssueOutput> outputs =
GitHubInvocableActions.createIssue(
new List<GitHubInvocableActions.CreateIssueInput>{ input }
);
Test.stopTest();
System.assertEquals(1, outputs.size());
System.assertEquals(true, outputs[0].success);
System.assertEquals(43, outputs[0].issueNumber);
System.assert(
outputs[0].issueUrl.contains('github.com'),
'URL should point to GitHub'
);
}
@IsTest
static void testInvocableCreateIssue_NoLabels() {
Test.setMock(HttpCalloutMock.class, new GitHubMock());
GitHubInvocableActions.CreateIssueInput input =
new GitHubInvocableActions.CreateIssueInput();
input.owner = 'testuser';
input.repo = 'salesforce-integration';
input.title = 'Issue without labels';
input.body = 'Testing blank labels.';
input.labelsCSV = '';
Test.startTest();
List<GitHubInvocableActions.CreateIssueOutput> outputs =
GitHubInvocableActions.createIssue(
new List<GitHubInvocableActions.CreateIssueInput>{ input }
);
Test.stopTest();
System.assertEquals(true, outputs[0].success);
}
@IsTest
static void testInvocableCreateIssue_Error() {
Test.setMock(
HttpCalloutMock.class,
new GitHubMock(403, '{"message":"Forbidden"}')
);
GitHubInvocableActions.CreateIssueInput input =
new GitHubInvocableActions.CreateIssueInput();
input.owner = 'testuser';
input.repo = 'private-repo';
input.title = 'Should fail';
input.body = 'This should return an error.';
Test.startTest();
List<GitHubInvocableActions.CreateIssueOutput> outputs =
GitHubInvocableActions.createIssue(
new List<GitHubInvocableActions.CreateIssueInput>{ input }
);
Test.stopTest();
System.assertEquals(false, outputs[0].success);
System.assert(
String.isNotBlank(outputs[0].errorMessage),
'Error message should be populated'
);
}
// -------------------------------------------------------
// Tests: GitHubModels deserialization
// -------------------------------------------------------
@IsTest
static void testDeserializeRepositoryList() {
String json = getMockRepositoryListJson();
List<GitHubModels.Repository> repos =
GitHubModels.deserializeRepositoryList(json);
System.assertEquals(2, repos.size());
System.assertEquals(false, repos[0].isPrivate);
System.assertEquals(true, repos[1].isPrivate);
}
@IsTest
static void testDeserializeSingleRepository() {
String json = getMockSingleRepoJson();
GitHubModels.Repository repo =
GitHubModels.deserializeRepository(json);
System.assertEquals(123456, repo.id);
System.assertEquals('Apex', repo.language);
}
@IsTest
static void testDeserializeIssueList() {
String json = getMockIssueListJson();
List<GitHubModels.Issue> issues =
GitHubModels.deserializeIssueList(json);
System.assertEquals(1, issues.size());
System.assertEquals(42, issues[0].number_x);
}
@IsTest
static void testDeserializeCommitList() {
String json = getMockCommitListJson();
List<GitHubModels.Commit> commits =
GitHubModels.deserializeCommitList(json);
System.assertEquals(2, commits.size());
System.assertEquals('abc123def456', commits[0].sha);
}
@IsTest
static void testIssueCreateRequest_Constructor() {
GitHubModels.IssueCreateRequest req =
new GitHubModels.IssueCreateRequest(
'Test Title',
'Test Body',
new List<String>{ 'bug', 'urgent' }
);
System.assertEquals('Test Title', req.title);
System.assertEquals('Test Body', req.body);
System.assertEquals(2, req.labels.size());
}
@IsTest
static void testErrorHandling_MalformedJson() {
Test.setMock(
HttpCalloutMock.class,
new GitHubMock(500, 'this is not json')
);
Test.startTest();
try {
GitHubService.listMyRepositories(5);
System.assert(false, 'Should have thrown GitHubApiException');
} catch (GitHubModels.GitHubApiException e) {
System.assert(
e.getMessage().contains('500'),
'Should still include status code even with malformed body'
);
}
Test.stopTest();
}
}
Test Coverage Strategy
Let us walk through what this test class covers and why:
| Area | Tests | What They Verify |
|---|---|---|
listMyRepositories | 4 tests | Success, null per_page, over-100 per_page, 401 error |
getRepository | 4 tests | Success, blank owner, blank repo, 404 error |
listIssues | 3 tests | Success with labels and user, null state default, 500 error |
createIssue | 4 tests | Success with labels, null labels, blank title, 403 error |
listCommits | 3 tests | Success with nested objects, null per_page, 404 error |
| Invocable action | 3 tests | Success with CSV labels, empty labels, error propagation |
| Deserialization | 5 tests | Repository list, single repo, issues, commits, request constructor |
| Error handling | 1 test | Malformed (non-JSON) error response body |
That is 27 test methods covering success paths, validation failures, HTTP errors, deserialization logic, and the invocable action wrapper. Every public method is exercised through at least its happy path and one error scenario. The mock class itself is tested implicitly through every callout test.
To check your coverage after deploying, run:
sfdx force:apex:test:run --tests GitHubServiceTest --codecoverage --resultformat human
You should see coverage well above 90% for GitHubService, GitHubModels, and GitHubInvocableActions.
Putting It All Together: How the Pieces Connect
Here is how the architecture flows from end to end:
Flow / LWC / Trigger
|
v
GitHubInvocableActions (orchestration layer)
|
v
GitHubService (HTTP communication layer)
|
v
Named Credential "GitHub" (authentication layer)
|
v
GitHub REST API (https://api.github.com)
|
v
GitHubModels (JSON deserialization layer)
Each layer has a single responsibility:
- GitHubModels — Defines data structures and handles JSON conversion.
- GitHubService — Constructs HTTP requests, sends them, and delegates parsing.
- GitHubInvocableActions — Adapts the service for declarative consumption (Flows).
- Named Credential — Manages authentication without exposing secrets in code.
If you need to add a new endpoint tomorrow — say, listing pull requests — you would:
- Add a
PullRequestinner class toGitHubModels. - Add a
listPullRequestsmethod toGitHubService. - Optionally add an invocable action in
GitHubInvocableActions. - Add test methods to
GitHubServiceTest.
No existing code changes. The architecture is open for extension.
Common Pitfalls and Troubleshooting
”Unauthorized endpoint” Error
If you see this error when running the callout, the Named Credential is misconfigured or the running user does not have the External Credential Principal assigned. Double-check the Permission Set.
”Unable to tunnel through proxy” Error
This usually indicates a Remote Site Setting issue. Verify that https://api.github.com is in your Remote Site Settings and is marked as Active.
Rate Limiting
The GitHub API allows 5,000 requests per hour for authenticated users. If you are running bulk operations, check the X-RateLimit-Remaining response header. You can access it in Apex with response.getHeader('X-RateLimit-Remaining').
Reserved Word Conflicts
We handled private, number, date, and commit in our wrapper classes. If you extend the integration and encounter other reserved words, apply the same pattern: rename the Apex property and use string replacement before deserialization.
Callout Limits
Salesforce enforces a limit of 100 callouts per transaction. If you need to fetch data from many repositories, consider using @future(callout=true) or Queueable Apex to spread the work across transactions.
Extending the Integration
Here are ideas for taking this project further:
- Lightning Web Component — Build a component that displays repository details, recent commits, and open issues directly on a Salesforce record page. The component’s JavaScript controller calls an Apex method, which calls
GitHubService. - Platform Events — Publish a platform event when a GitHub issue is created from Salesforce, allowing other systems to react.
- Scheduled Apex — Run a nightly job that syncs open GitHub issues into a custom Salesforce object for reporting and dashboards.
- Error Logging — Create a custom object to log every callout: endpoint, status code, response time, and any errors. This gives you an audit trail for troubleshooting production issues.
- Webhook Listener — Expose a Salesforce REST endpoint that GitHub can call via webhooks. When a commit is pushed or an issue is closed, GitHub notifies Salesforce in real time. This turns the integration from pull-based to push-based.
Summary
In this post we built a complete, production-quality REST integration between Salesforce and GitHub. Here is what we covered:
- Named Credentials — External Credential with a Personal Access Token, Named Credential pointing to
api.github.com, Permission Set for principal access, and a Remote Site Setting for network-level authorization. - Postman testing — Verified five GitHub API endpoints (list repos, get repo, list issues, create issue, list commits) before writing any Apex.
- GitHubModels — A wrapper class with inner classes for Repository, Issue, Commit, User, Label, and error responses. Static methods handle reserved-word conflicts during deserialization.
- GitHubService — A stateless service class with five public methods, centralized HTTP execution, input validation, URL encoding, and structured error handling with a custom exception type.
- GitHubInvocableActions — An invocable action that exposes issue creation to Salesforce Flows, with proper input/output classes and error handling.
- GitHubServiceTest — 27 test methods covering success paths, validation errors, HTTP errors, deserialization, the invocable action, and malformed response handling. A flexible
HttpCalloutMockimplementation routes mock responses based on endpoint and HTTP method.
This is the pattern you will use for virtually every REST integration in Salesforce. The specifics change — different APIs, different authentication schemes, different data models — but the architecture stays the same: Named Credentials for auth, wrapper classes for data, a service class for communication, and thorough test coverage with mocks.
In the next post, Part 52, we will tackle the other side of the integration coin: SOAP Integration Project. We will connect Salesforce to a SOAP-based web service, generate Apex classes from WSDL, and see how the patterns differ from REST. See you there.