Tutorial on how to add a badge for a service
This tutorial should help you add a service to shields.io in form of a badge. You will need to learn to use JavaScript, Git and GitHub, however, this document will guide you through that journey if you are a beginner. Please improve the tutorial while you read it.
(1) Reading
- Contributing Guidance
- Documentation for the Shields Core API
- You can also read previous merged pull-requests with the 'service-badge' label to see how other people implemented their badges.
(2) Setup
Pre-requisites
Git
You should have git installed. If you do not, install git and learn about the GitHub workflow.
Node, NPM
Node >=20 and NPM 9.x or 10.x is required. If you don't already have them, install node and npm: https://nodejs.org/en/download/
Setup a dev install
- Fork this repository.
- Clone the fork
git clone git@github.com:YOURGITHUBUSERNAME/shields.git
cd shields
- Install project dependencies
npm ci
- Run the badge server and the frontend dev server
npm start
- Visit the website to check the front-end is loaded: http://localhost:3000/.
In case you get the "getaddrinfo ENOTFOUND localhost" error, visit http://127.0.0.1:3000/ instead or take a look at this issue.
(3) Open an Issue
Before you want to implement your service, you may want to open an issue and describe what you have in mind:
- What is the badge for?
- Which API do you want to use?
You may additionally proceed to say what you want to work on. This information allows other humans to help and build on your work.
(4) Implementing
(4.1) Structure and Layout
Service badge code is stored in the /services directory. Each service has a directory for its files:
-
In files ending with
.service.js
, you can find the code which handles incoming requests and generates the badges. Sometimes, code for a service can be re-used. This might be the case when you add a badge for an API which is already used by other badges.Imagine a service that lives at https://img.shields.io/example/some-param-here.
-
For services with a single badge, the badge code will generally be stored in
/services/example/example.service.js
. If you add a badge for a new API, create a new directory.Example: Docs.rs
-
For service families with multiple badges we usually store the code for each badge in its own file like this:
/services/example/example-downloads.service.js
/services/example/example-version.service.js
etc.
Example: ruby gems
-
-
In files ending with
.tester.js
, you can find the code which uses the shields server to test if the badges are generated correctly. There is a chapter on Tests.
(4.2) Our First Badge
All service badge classes inherit from BaseService or another class which extends it. Other classes implement useful behavior on top of BaseService.
- BaseJsonService implements methods for performing requests to a JSON API and schema validation.
- BaseXmlService implements methods for performing requests to an XML API and schema validation.
- BaseYamlService implements methods for performing requests to a YAML API and schema validation.
- BaseTomlService implements methods for performing requests to a TOML API and schema validation.
- BaseSvgScrapingService implements methods for retrieving information from existing third-party badges.
- BaseGraphqlService implements methods for performing requests to a GraphQL API and schema validation.
- If you are contributing to a service family, you may define a common super class for the badges or one may already exist.
As a first step we will look at the code for an example which generates a badge without contacting an API.
// (1)
import { BaseService } from '../index.js'
// (2)
export default class Example extends BaseService {
// (3)
static category = 'build'
// (4)
static route = { base: 'example', pattern: ':text' }
// (5)
async handle({ text }) {
return {
label: 'example',
message: text,
color: 'blue',
}
}
}
Description of the code:
- Our service badge class will extend
BaseService
so we need to import it. - Our module must export a class which extends
BaseService
. - Returns the name of the category to sort this badge into (eg. "build"). Used to sort the examples on the main shields.io website. Here is the list of the valid categories. See section 4.4 for more details.
route
declares the URL path at which the service operates. It also maps components of the URL path to handler parameters.base
defines the first part of the URL that doesn't change, e.g./example/
.pattern
defines the variable part of the route, everything that comes after/example/
. It can include any number of named parameters. These are converted into regular expressions bypath-to-regexp
. Because a service instance won't be created until it's time to handle a request, the route and other metadata must be obtained by examining the classes themselves. That's why they're markedstatic
.- There is additional documentation on conventions for designing badge URLs
- All badges must implement the
async handle()
function that receives parameters to render the badge. Parameters ofhandle()
will match the name defined inroute
Because we're capturing a single variable calledtext
our function signature isasync handle({ text })
.async
is needed to let JavaScript do other things while we are waiting for result from external API. Although in this simple case, we don't make any external calls. Ourhandle()
function should return an object with 3 properties:label
: the text on the left side of the badgemessage
: the text on the right side of the badge - here we are passing through the parameter we captured in the route regexcolor
: the background color of the right side of the badge
The process of turning this object into an image is handled automatically by the BaseService
class.
To try out this example badge:
- Copy and paste this code into a new file in
/services/example/example.service.js
- The server should restart on its own. (If it doesn't for some reason, quit
the running server with
Control+C
, then start it again withnpm start
.) - Visit the badge at http://localhost:8080/example/foo. It should look like this:
(4.3) Querying an API
The example above was completely static. In order to make a useful service badge we will need to get some data from somewhere. The most common case is that we will query an API which serves up some JSON data, but other formats (e.g: XML) may be used.
This example is based on the Ruby Gems version badge:
// (1)
import { renderVersionBadge } from '../version.js'
// (2)
import { BaseJsonService } from '../index.js'
// (3)
import Joi from 'joi'
const schema = Joi.object({
version: Joi.string().required(),
}).required()
// (4)
export default class GemVersion extends BaseJsonService {
// (5)
static category = 'version'
// (6)
static route = { base: 'gem/v', pattern: ':gem' }
// (7)
static defaultBadgeData = { label: 'gem' }
// (10)
static render({ version }) {
return renderVersionBadge({ version })
}
// (9)
async fetch({ gem }) {
return this._requestJson({
schema,
url: `https://rubygems.org/api/v1/gems/${gem}.json`,
})
}
// (8)
async handle({ gem }) {
const { version } = await this.fetch({ gem })
return this.constructor.render({ version })
}
}
Description of the code:
-
In this case we are making a version badge, which is a common pattern. Instead of directly returning an object in this badge we will use a helper function to format our data consistently. There are a variety of helper functions to help with common tasks in
/services
. Some useful generic helpers can be found in: -
Our badge will query a JSON API so we will extend
BaseJsonService
instead ofBaseService
. This contains some helpers to reduce the need for boilerplate when calling a JSON API. -
We perform input validation by defining a schema which we expect the JSON we receive to conform to. This is done using Joi. Defining a schema means we can ensure the JSON we receive meets our expectations and throw an error if we receive unexpected input without having to explicitly code validation checks. The schema also acts as a filter on the JSON object. Any properties we're going to reference need to be validated, otherwise they will be filtered out. In this case our schema declares that we expect to receive an object which must have a property called 'version', which is a string. There is further documentation on input validation.
-
Our module exports a class which extends
BaseJsonService
-
Returns the name of the category to sort this badge into (eg. "build"). Used to sort the examples on the main shields.io website. Here is the list of the valid categories. See section 4.4 for more details.
-
As with our previous badge, we need to declare a route. This time we will capture a variable called
gem
. -
We can use
defaultBadgeData
to set a defaultcolor
,logo
and/orlabel
. Ifhandle()
doesn't return any of these keys, we'll use the default. Instead of explicitly setting the label text when we return a badge object, we'll usedefaultBadgeData
here to define it declaratively. -
We now jump to the bottom of the example code to the function all badges must implement:
async handle()
. This is the function the server will invoke to handle an incoming request. Because our URL pattern captures a variable calledgem
, our function signature isasync handle({ gem })
. We usually separate the process of generating a badge into 2 stages or concerns: fetch and render. Thefetch()
function is responsible for calling an API endpoint to get data. Therender()
function formats the data for display. In a case where there is a lot of calculation or intermediate steps, this pattern may be thought of as fetch, transform, render and it might be necessary to define some helper functions to assist with the 'transform' step. -
Working our way upward, the
async fetch()
method is responsible for calling an API endpoint to get data. ExtendingBaseJsonService
gives us the helper function_requestJson()
. Note here that we pass the schema we defined in step 3 as an argument._requestJson()
will deal with validating the response against the schema and throwing an error if necessary._requestJson()
automatically adds an Accept header, checks the status code, parses the response as JSON, and returns the parsed response._requestJson()
uses got to perform the HTTP request. Options can be passed to got, including method, query string, and headers. If headers are provided they will override the ones automatically set by_requestJson()
. There is no need to specify json, as the JSON parsing is handled by_requestJson()
. See thegot
docs for supported options.- Error messages corresponding to each status code can be returned by passing a dictionary of status codes -> messages in
httpErrors
. - A more complex call to
_requestJson()
might look like this:return this._requestJson({ schema: mySchema, url, options: { searchParams: { branch: 'master' } }, httpErrors: { 401: 'private application not supported', 404: 'application not found', }, })
-
Upward still, the
static render()
method is responsible for formatting the data for display.render()
is a pure function so we can make it astatic
method. By convention we declare functions which don't referencethis
asstatic
. We could explicitly return an object here, as we did in the previous example. In this case, we will hand the version string off torenderVersionBadge()
which will format it consistently and set an appropriate color. BecauserenderVersionBadge()
doesn't return alabel
key, the default label we defined indefaultBadgeData
will be used when we generate the badge.
This code allows us to call this URL https://img.shields.io/gem/v/formatador to generate this badge:
It is also worth considering the code we haven't written here. Note that our example doesn't contain any explicit error handling code, but our badge handles errors gracefully. For example, if we call https://img.shields.io/gem/v/does-not-exist we render a 'not found' badge because https://rubygems.org/api/v1/gems/this-package-does-not-exist.json returns a 404 Not Found
status code. When dealing with well-behaved APIs, some of our error handling will be handled implicitly in BaseJsonService
.
Specifically BaseJsonService
will handle the following errors for us:
- API does not respond
- API responds with a non-
200 OK
status code - API returns a response which can't be parsed as JSON
- API returns a response which doesn't validate against our schema
Sometimes it may be necessary to manually throw an exception to deal with a non-standard error condition. If so, there are several standard exceptions that can be used. The errors are documented at errors and can be imported via the import shortcut and then thrown:
import { NotFound } from '../index.js'
throw new NotFound({ prettyMessage: 'package not found' })
(4.4) Adding Documentation to the Frontend
To render the shields.io website, we produce an OpenAPI 3 specification which describes the available badge endpoints. Then we use that specification to render the frontend.
Once we have implemented our badge, we want to add it to the index so that users can discover it. We will do this by adding an additional object openApi
to our class. This object contains an OpenAPI Paths Object describing the endpoint or endpoints exposed by this service class.
// (1)
import { pathParams } from '../index.js'
export default class GemVersion extends BaseJsonService {
// ...
// (2)
static category = 'version'
static openApi = {
// (3)
'/gem/v/{gem}': {
// (4)
get: {
// (5)
summary: 'Gem Version',
description:
'[Ruby Gems](https://rubygems.org/) is a registry for ruby libraries',
// (6)
parameters: pathParams({
name: 'gem',
description: 'Name of the Ruby Gem',
example: 'formatador',
}),
},
},
}
}
-
There are four helper functions we can use to assemble Open API Parameter objects. These are:
pathParam
- returns a single Parameter object describing a single path parameterpathParams
- returns an array of path parameter objectsqueryParam
- returns a single Parameter object describing a single query parameterqueryParams
- returns an array of query parameter objects
These four helper functions are documented in more detail at http://contributing.shields.io/module-core_base-service_openapi.html
-
We defined category earlier in the tutorial. The
category
property defines which heading in the index our example will appear under. -
The keys of the
openApi
object are routes. In this case we only need to describe one route. In some cases, a service class can define more than one badge route. Open API doesn't have the concept of optional path parameters (more specifically,in: 'path'
impliesrequired: true
) so if there are any optional path parameters in our route, ouropenApi
object needs to describe two URLs: one without the optional parameter, and another with it. -
The HTTP method. Shields only allows GET requests, so this is always
get
. -
summary
(required) is a short title or description of the badge.description
is an optional longer description or additional documentation. We can use markdown or HTML syntax inside thedescription
field. -
parameters
is an array of Open API Parameter objects describing any parameters we can pass to this route. This array should include all path parameters included in the key that this value object describes and all relevant query parameters. As a minimum, we need to supplyname
andexample
. The example must be a valid example of a value we can provide for this parameter. In this case we need a valid ruby gem, so we've picked formatador. There are also optional keys we can pass. The codeparameters: pathParams({ name: 'gem', description: 'Name of the Ruby Gem', example: 'formatador', })
is equivalent to
parameters: [ { name: 'gem', in: 'path', required: true, schema: { type: 'string' }, example: 'formatador', description: 'Name of the Ruby Gem', }, ]
but we have used the helper function
pathParams
to imply some defaults and reduce the amount of code we need to write by hand.
Save, run npm start
, and you can see it locally.
If you update openApi
, you don't have to restart the server. Run npm run prestart
in another terminal window and the frontend will update.
(4.5) Write Tests
When creating a badge for a new service or changing a badge's behavior, tests should be included. They serve several purposes:
- They speed up future contributors when they are debugging or improving a badge.
- If the contributors would like to change your badge, chances are, they forget edge cases and break your code. Tests may give hints in such cases.
- The contributor and reviewer can easily verify the code works as intended.
- When a badge stops working on the live server, maintainers can find out right away.
There is a dedicated tutorial for tests in the service-tests folder. Please follow it to include tests on your pull-request.
(4.6) Update the Docs
If your submission requires an API token or authentication credentials, please update server-secrets.md. You should explain what the token or credentials are for and how to obtain them.
(5) Create a Pull Request
Once you have implemented a new badge:
- Before submitting your changes, please review the coding guidelines.
- Create a pull-request to propose your changes.
- CI will check the tests pass and that your code conforms to our coding standards.
- We also use Danger to check for some common problems. The first comment on your pull request will be posted by a bot. If there are any errors or warnings raised, please review them.
- One of the maintainers will review your contribution.
- We'll work with you to progress your contribution suggesting improvements if necessary. Although there are some occasions where a contribution is not appropriate, if your contribution conforms to our guidelines we'll aim to work towards merging it. The majority of pull requests adding a service badge are merged.
- If your contribution is merged, the final comment on the pull request will be an automated post which you can monitor to tell when your contribution has been deployed to production.