Maintaining consistency, quality, and adherence to design standards is paramount for teams using APIs. API specifications like OpenAPI and AsyncAPI provide a blueprint, but ensuring these blueprints are followed correctly across numerous services and teams can be a daunting challenge. This is where API linting tools come into play, and Spectral stands out as a flexible and powerful open-source option. When combined with TypeScript, Spectral empowers developers to create robust, type-safe custom rules, elevating API governance to a new level.
This tutorial will guide you through the process of leveraging Spectral with TypeScript, from initial setup to crafting sophisticated custom validation logic. We'll explore how TypeScript enhances the development of Spectral rules, leading to more maintainable and reliable API linting solutions.
Want an integrated, All-in-One platform for your Developer Team to work together with maximum productivity?
Apidog delivers all your demans, and replaces Postman at a much more affordable price!
Understanding Spectral: The Guardian of Your API Specifications
Before diving into the TypeScript integration, let's establish what Spectral is and why it's a valuable tool in your API development toolkit.
Spectral is an open-source JSON/YAML linter with a primary focus on API description formats like OpenAPI (v2 and v3) and AsyncAPI. Its purpose is to help enforce API design guidelines, detect common errors, and ensure consistency across your API landscape. Think of it as ESLint or TSLint, but specifically for your API contracts.
Key Benefits of Using Spectral:
- Consistency: Enforces uniform API design across teams and projects.
- Quality Assurance: Catches errors and bad practices early in the development lifecycle.
- Improved Collaboration: Provides a shared understanding of API standards.
- Automation: Integrates seamlessly into CI/CD pipelines for automated validation.
- Extensibility: Allows for the creation of custom rules tailored to specific organizational needs.
- Format Agnostic Core: While excelling at OpenAPI/AsyncAPI, its core can lint any JSON/YAML structure.
Spectral operates based on rulesets. A ruleset is a collection of rules, where each rule targets specific parts of your API document (using JSONPath expressions) and applies validation logic. Spectral comes with built-in rulesets (e.g., spectral:oas
for OpenAPI standards), but its true power lies in the ability to define custom rulesets.
Why TypeScript for Spectral Custom Rules?
While Spectral rulesets can be defined in YAML or JavaScript (.js
files), using TypeScript for developing custom functions offers significant advantages:
- Type Safety: TypeScript's static typing catches errors at compile-time, reducing runtime surprises in your custom linting logic. This is crucial for complex rules.
- Improved Developer Experience: Autocompletion, refactoring capabilities, and better code navigation in IDEs make writing and maintaining custom functions easier.
- Enhanced Readability and Maintainability: Explicit types make the intent and structure of your custom functions clearer, especially for teams.
- Modern JavaScript Features: Utilize modern ES features with confidence, as TypeScript compiles down to compatible JavaScript.
- Better Testability: Typing makes it easier to write robust unit tests for your custom Spectral functions.
By writing your custom Spectral functions in TypeScript, you bring the same rigor and tooling benefits to your API governance code as you do to your application code.
Setting Up Your Spectral and TypeScript Environment
Let's get our hands dirty and set up the necessary tools.
Prerequisites:
- Node.js and npm (or yarn): Spectral is a Node.js application. Ensure you have Node.js (LTS version recommended) and npm (or yarn) installed.
- A TypeScript Project: You'll need a TypeScript project or be willing to set one up.
Installation Steps:
First, you'll need the Spectral CLI to run linting operations and test your rules. It's often useful to install it globally or use npx
.Bash
npm install -g @stoplight/spectral-cli
# or
yarn global add @stoplight/spectral-cli
For developing custom rules programmatically and using Spectral's core libraries within a TypeScript project, install the necessary Spectral packages:Bash
npm install @stoplight/spectral-core @stoplight/spectral-functions @stoplight/spectral-rulesets typescript ts-node --save-dev
# or
yarn add @stoplight/spectral-core @stoplight/spectral-functions @stoplight/spectral-rulesets typescript ts-node --dev
Let's break down these packages:
@stoplight/spectral-core
: The heart of Spectral, containing the linting engine.@stoplight/spectral-functions
: Provides a collection of built-in functions that your rules can use (e.g.,alphabetical
,defined
,pattern
,truthy
,xor
).@stoplight/spectral-rulesets
: Offers pre-defined rulesets likespectral:oas
(for OpenAPI) andspectral:asyncapi
.typescript
: The TypeScript compiler.ts-node
: Allows you to run TypeScript files directly without pre-compiling, useful for development.
Configure TypeScript:
Create a tsconfig.json
file in your project root if you don't have one already. A basic configuration might look like this:JSON
{
"compilerOptions": {
"target": "es2020", // Or a newer version
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist", // Output directory for compiled JavaScript
"rootDir": "./src", // Source directory for your TypeScript files
"resolveJsonModule": true // Allows importing JSON files
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}
Adjust outDir
and rootDir
according to your project structure. We'll assume your custom TypeScript functions will reside in the src
directory.
Core Spectral Concepts: Rules, Rulesets, and Functions
Before writing TypeScript functions, let's solidify our understanding of Spectral's main components.
Rules:
A rule defines a specific check to be performed. Key properties of a rule include:
description
: A human-readable explanation of the rule.message
: The error or warning message to display if the rule is violated. Can include placeholders like{{error}}
,{{path}}
,{{value}}
.severity
: Defines the impact of the rule violation. Can beerror
,warn
,info
, orhint
.given
: A JSONPath expression (or an array of them) specifying which parts of the document the rule applies to.then
: Defines the action to take on the targeted values. This usually involves applying one or more functions.function
: The name of the built-in or custom function to execute.functionOptions
: Options to pass to the function.formats
: An array specifying which document formats this rule applies to (e.g.,oas3
,oas2
,asyncapi2
).
Rulesets:
A ruleset is a YAML or JavaScript file (e.g., .spectral.yaml, .spectral.js, or .spectral.ts when compiled) that groups rules. It can also:
extends
: Inherit rules from other rulesets (e.g., built-in Spectral rulesets or shared organizational rulesets).rules
: An object containing your custom rule definitions.functionsDir
: Specifies a directory where custom JavaScript function files are located.functions
: An array of custom functions (less common when usingfunctionsDir
or programmatic setup).
Functions:
Functions are the core logic units that perform the actual validation. Spectral provides many built-in functions like:
truthy
: Checks if a value is truthy.falsy
: Checks if a value is falsy.defined
: Checks if a property is defined.undefined
: Checks if a property is undefined.pattern
: Checks if a string matches a regex.alphabetical
: Checks if array elements or object keys are in alphabetical order.length
: Checks the length of a string or array.schema
: Validates a value against a JSON Schema.
The real power comes when these aren't enough, and you need to write your own custom functions – this is where TypeScript shines.
Crafting Your First Custom Spectral Function in TypeScript
Let's create a simple custom function. Imagine we want to enforce a rule that all API operation summaries must be title-cased and not exceed 70 characters.
Step 1: Define the Custom Function in TypeScript
Create a file, say src/customFunctions.ts
:TypeScript
import type { IFunction, IFunctionResult } from '@stoplight/spectral-core';
interface TitleCaseLengthOptions {
maxLength: number;
}
// Custom function to check if a string is title-cased and within a max length
export const titleCaseAndLength: IFunction<string, TitleCaseLengthOptions> = (
targetVal,
options,
context
): IFunctionResult[] | void => {
const results: IFunctionResult[] = [];
if (typeof targetVal !== 'string') {
// Should not happen if 'given' path points to a string, but good practice
return [{ message: `Value at path '${context.path.join('.')}' must be a string.` }];
}
// Check for Title Case (simple check: first letter of each word is uppercase)
const words = targetVal.split(' ');
const isTitleCase = words.every(word => word.length === 0 || (word[0] === word[0].toUpperCase() && (word.length === 1 || word.substring(1) === word.substring(1).toLowerCase())));
if (!isTitleCase) {
results.push({
message: `Summary "${targetVal}" at path '${context.path.join('.')}' must be in Title Case.`,
path: [...context.path], // Path to the violating element
});
}
// Check for length
const maxLength = options?.maxLength || 70; // Default to 70 if not provided
if (targetVal.length > maxLength) {
results.push({
message: `Summary "${targetVal}" at path '${context.path.join('.')}' exceeds maximum length of ${maxLength} characters. Current length: ${targetVal.length}.`,
path: [...context.path],
});
}
return results;
};
Explanation:
- We import
IFunction
andIFunctionResult
from@stoplight/spectral-core
for type safety.IFunction<T = unknown, O = unknown>
takes two generic arguments:T
for the type oftargetVal
(the value being linted) andO
for the type ofoptions
passed to the function. targetVal
: The actual value from the API document that thegiven
JSONPath points to. We've typed it asstring
.options
: An object containing options passed from the rule definition (e.g.,{ "maxLength": 70 }
). We created an interfaceTitleCaseLengthOptions
for these options.context
: Provides contextual information about the linting process, including:path
: An array of path segments leading to thetargetVal
.document
: The entire parsed document.rule
: The current rule being processed.- The function returns an array of
IFunctionResult
objects if there are violations, orvoid
/undefined
/empty array if there are no issues. EachIFunctionResult
must have amessage
and optionally apath
(if different fromcontext.path
). - Our logic checks for a simple title case format and the maximum length.
Step 2: Compile the TypeScript Function
If you plan to use this function with a .spectral.yaml
or .spectral.js
ruleset that points to a functionsDir
, you'll need to compile your TypeScript to JavaScript.
Add a build script to your package.json
:JSON
{
"scripts": {
"build": "tsc"
}
}
Run npm run build
or yarn build
. This will compile src/customFunctions.ts
to dist/customFunctions.js
(based on our tsconfig.json
).
Step 3: Create a Ruleset File
Let's create a ruleset, for example, .spectral.js
(or .spectral.ts
if you prefer a fully TypeScript-driven setup, see next section).
If using a JavaScript ruleset file directly referencing the compiled functions:JavaScript
// .spectral.js
const { titleCaseAndLength } = require('./dist/customFunctions'); // Path to your compiled JS functions
module.exports = {
extends: [['@stoplight/spectral-rulesets/dist/rulesets/oas', 'recommended']],
rules: {
'operation-summary-title-case-length': {
description: 'Operation summaries must be title-cased and not exceed 70 characters.',
message: '{{error}}', // The message will come from the custom function
given: '$.paths[*][*].summary', // Targets all operation summaries
severity: 'warn',
formats: ["oas3"], // Apply only to OpenAPI v3 documents
then: {
function: titleCaseAndLength, // Directly reference the imported function
functionOptions: {
maxLength: 70,
},
},
},
// You can add more rules here
},
};
Alternatively, if using .spectral.yaml
and functionsDir
:
First, ensure your dist
directory contains an index.js
that exports your functions, or your customFunctions.js
directly exports them. For instance, if dist/customFunctions.js
has exports.titleCaseAndLength = ...;
, you could do:YAML
# .spectral.yaml
extends:
- ["@stoplight/spectral-rulesets/dist/rulesets/oas", "recommended"]
functionsDir: "./dist" # Directory containing compiled custom JS functions
rules:
operation-summary-title-case-length:
description: "Operation summaries must be title-cased and not exceed 70 characters."
message: "{{error}}"
given: "$.paths[*][*].summary"
severity: "warn"
formats: ["oas3"]
then:
function: customFunctions#titleCaseAndLength # Assuming functions are exported from customFunctions.js
functionOptions:
maxLength: 70
Here, customFunctions#titleCaseAndLength
tells Spectral to look for customFunctions.js
(or customFunctions/index.js
) in the functionsDir
and use the exported titleCaseAndLength
function.
Step 4: Create a Sample OpenAPI Document
Let's create a simple openapi.yaml
file to test our rule:YAML
# openapi.yaml
openapi: 3.0.0
info:
title: Sample API
version: 1.0.0
paths:
/items:
get:
summary: retrieves all items from the store # Incorrect: not title case
responses:
'200':
description: A list of items.
post:
summary: Adds A New Item To The Ever Expanding Collection Of Items In The Store # Incorrect: too long
responses:
'201':
description: Item created.
/users:
get:
summary: Get User Details # Correct
responses:
'200':
description: User details.
Step 5: Run Spectral
Now, execute Spectral CLI against your OpenAPI document:Bash
spectral lint openapi.yaml --ruleset .spectral.js
# or if using YAML ruleset (often auto-detected if named .spectral.yaml)
spectral lint openapi.yaml
Expected Output:
You should see warnings similar to this:
openapi.yaml
2:10 warning operation-summary-title-case-length Summary "retrieves all items from the store" at path 'paths./items.get.summary' must be in Title Case. paths./items.get.summary
6:10 warning operation-summary-title-case-length Summary "Adds A New Item To The Ever Expanding Collection Of Items In The Store" at path 'paths./items.post.summary' exceeds maximum length of 70 characters. Current length: 78. paths./items.post.summary
✖ 2 problems (0 errors, 2 warnings, 0 infos, 0 hints)
This output confirms that our custom TypeScript function, compiled to JavaScript, is correctly identifying violations.
Programmatic Usage of Spectral with TypeScript Rulesets
For more complex scenarios or tighter integration into applications, you might want to use Spectral programmatically and define your ruleset entirely in TypeScript. This bypasses the need for a separate .spectral.yaml
or .spectral.js
file if desired, and allows for dynamic ruleset construction.
Create a TypeScript file for your linter, for example src/linter.ts
:TypeScript
import { Spectral, Document } from '@stoplight/spectral-core';
import { oas } from '@stoplight/spectral-rulesets';
import { truthy } from '@stoplight/spectral-functions'; // Example built-in function
import { titleCaseAndLength } from './customFunctions'; // Your custom TS function
import type { ISpectralDiagnostic } from '@stoplight/spectral-core';
import * as fs from 'fs/promises';
import * as path from 'path';
// Define the Spectral instance
const spectral = new Spectral();
// Load built-in rulesets and functions
spectral.setRuleset({
extends: [[oas, 'recommended']], // Extends the recommended OpenAPI rules
rules: {
'operation-summary-title-case-length': {
description: 'Operation summaries must be title-cased and not exceed 70 characters.',
message: '{{error}}',
given: '$.paths[*][*].summary',
severity: 'warn',
formats: ["oas3"],
then: {
function: titleCaseAndLength, // Directly use the TypeScript function
functionOptions: {
maxLength: 70,
},
},
},
'info-contact-defined': { // Example using a built-in function
description: 'Info object should have a contact object.',
message: 'API contact information is missing.',
given: '$.info',
severity: 'warn',
formats: ["oas3"],
then: {
field: 'contact',
function: truthy, // Using a built-in function
},
},
},
});
// Function to lint a document
export async function lintDocument(filePath: string): Promise<ISpectralDiagnostic[]> {
try {
const absolutePath = path.resolve(filePath);
const fileContent = await fs.readFile(absolutePath, 'utf-8');
// Create a Spectral Document object
// The second argument (URI) is important for resolving relative $refs if any
const document = new Document(fileContent, undefined, absolutePath);
const results = await spectral.run(document);
return results;
} catch (error) {
console.error('Error linting document:', error);
return [];
}
}
// Example usage (e.g., in a script or another module)
async function main() {
const diagnostics = await lintDocument('openapi.yaml'); // Path to your API spec
if (diagnostics.length > 0) {
console.log('API Linting Issues Found:');
diagnostics.forEach(issue => {
console.log(
`- [${issue.severity === 0 ? 'Error' : issue.severity === 1 ? 'Warning' : issue.severity === 2 ? 'Info' : 'Hint'}] ${issue.code} (${issue.message}) at ${issue.path.join('.')}`
);
});
} else {
console.log('No API linting issues found. Great job!');
}
}
// If you want to run this file directly with ts-node
if (require.main === module) {
main().catch(console.error);
}
To run this:Bash
npx ts-node src/linter.ts
This approach provides maximum flexibility:
- No Compilation Step for Ruleset Definition: The ruleset itself is defined in TypeScript. Your custom functions still need to be imported (either as TS if
ts-node
is used, or compiled JS if running pure Node). - Dynamic Rules: You can programmatically construct or modify rulesets based on runtime conditions.
- Direct Type Usage: You directly use your imported TypeScript functions within the ruleset definition, enhancing type safety and IDE integration.
Advanced Custom Function Techniques
Let's explore some more advanced aspects of creating custom functions.
Asynchronous Custom Functions:
If your custom function needs to perform asynchronous operations (e.g., fetching an external resource to validate against, though use with caution for performance), you can define it as an async function.TypeScript
import type { IFunction, IFunctionResult } from '@stoplight/spectral-core';
export const checkExternalResource: IFunction<string, { url: string }> =
async (targetVal, options, context): Promise<IFunctionResult[] | void> => {
try {
const response = await fetch(`${options.url}/${targetVal}`);
if (!response.ok) {
return [{ message: `Resource '${targetVal}' not found at ${options.url}. Status: ${response.status}` }];
}
} catch (error: any) {
return [{ message: `Error fetching resource '${targetVal}': ${error.message}` }];
}
};
Spectral will correctly await
your asynchronous function.
Accessing the Full Document and Resolved Values:
The context.document object gives you access to the entire parsed document. More powerfully, context.document.resolved provides the fully dereferenced version of the document, which is essential when dealing with $ref pointers.TypeScript
import type { IFunction, IFunctionResult } from '@stoplight/spectral-core';
import {isPlainObject} from '@stoplight/json';
// Example: Ensure a referenced schema has a specific property
export const referencedSchemaHasProperty: IFunction<{$ref: string}, { propertyName: string }> = (
targetVal, // This will be the object like { $ref: '#/components/schemas/MySchema' }
options,
context
): IFunctionResult[] | void => {
if (!targetVal.$ref) return;
// The `context.document.resolveAnchor` method can find the resolved value for a $ref
const resolvedValue = context.document.resolveAnchor(targetVal.$ref);
if (!resolvedValue || !isPlainObject(resolvedValue.value)) {
return [{ message: `Could not resolve $ref: ${targetVal.$ref}` }];
}
const schemaProperties = resolvedValue.value.properties as Record<string, unknown> | undefined;
if (!schemaProperties || schemaProperties[options.propertyName] === undefined) {
return [{
message: `The schema referenced by "${targetVal.$ref}" at path '${context.path.join('.')}' must have property "${options.propertyName}".`,
path: [...context.path, '$ref'] // Point to the $ref itself
}];
}
};
This function would be used with a given
path that targets objects containing $ref
, for instance: $.paths[*].*.responses.*.content.*.schema
.
Working with formats:
Ensure your custom rules specify the formats they apply to (e.g., oas2, oas3, asyncapi2). This prevents rules from running on incompatible document types. You can access the detected format within your function via context.document.formats.
Organizing and Extending Rulesets
As your collection of custom rules grows, organization becomes key.
- Modular Rulesets: Break down large rulesets into smaller, focused files. You can use the
extends
property to combine them. JavaScript
// .spectral.js (main ruleset)
module.exports = {
extends: ['./rulesets/common-rules.js', './rulesets/security-rules.js'],
// ... other rules or overrides
};
- Shared Rulesets: Publish rulesets as npm packages to share them across different projects or teams. The
extends
property can then reference these packages. functionsDir
for Simplicity: If you have many custom JS functions (compiled from TS),functionsDir
in a.spectral.yaml
ruleset can be simpler than listing them all or using a fully programmatic setup. Just ensure your compiled JS files export the functions correctly.
Integrating Spectral into Your Workflow
The true power of API linting is realized when it's automated.
- CI/CD Pipelines: Integrate
spectral lint
commands into your GitHub Actions, GitLab CI, Jenkins, or other CI/CD pipelines. Fail the build if critical errors are detected. YAML
# Example GitHub Action step
- name: Lint API Specification
run: spectral lint ./path/to/your/api-spec.yaml --ruleset ./.spectral.js --fail-severity=error
- Git Hooks: Use tools like Husky to run Spectral on pre-commit or pre-push hooks, catching issues before they even reach the repository.
- IDE Integration: Look for Spectral extensions for your IDE (e.g., VS Code) to get real-time feedback as you write your API specifications.
Best Practices for Spectral and TypeScript Custom Rules
- Clear Descriptions and Messages: Write meaningful
description
andmessage
properties for your rules. Messages should guide the user on how to fix the issue. - Appropriate Severity Levels: Use
error
for critical violations,warn
for important suggestions,info
for informational checks, andhint
for minor suggestions. - Precise
given
Paths: Make your JSONPath expressions as specific as possible to target only the intended nodes. This improves performance and reduces false positives. - Idempotent Functions: Custom functions should be pure where possible – given the same input, they should produce the same output without side effects.
- Test Your Custom Rules: Write unit tests for your custom TypeScript functions to ensure they behave as expected with various inputs.
- Performance Considerations: Be mindful of complex computations or numerous
schema
function calls in performance-sensitive rules, especially for very large documents. Test the performance of your rulesets. - Keep TypeScript Types Updated: Ensure your custom functions correctly type the
targetVal
andoptions
they expect. This is crucial for maintainability.
Conclusion: Elevate Your API Governance with Spectral and TypeScript
Spectral offers a robust framework for API linting, and by leveraging TypeScript for custom rule development, you bring enhanced type safety, developer experience, and maintainability to your API governance strategy. Whether you're enforcing OpenAPI best practices, company-specific naming conventions, or complex business logic within your API designs, the combination of Spectral's flexible rule engine and TypeScript's strong typing system provides a powerful solution.
By integrating Spectral into your development lifecycle, from IDE feedback to CI/CD pipelines, you can ensure your APIs are consistent, high-quality, and adhere to your defined standards, ultimately leading to more reliable and easier-to-consume APIs. Start small, iterate on your rulesets, and empower your teams with the tools to build better APIs.