Just want to use OpenAPI Spec Generator

Problem Solving
Published 2024-12-16
POST

Today, we'll learn how to handle uncommon problems in everyday work. We'll explore solutions step by step, starting from basic problem-solving approaches to out-of-the-box solutions. Sometimes we might have limited options because, in reality, our world is often more complex than we imagine. But regardless of the reason, let's find ways to solve it.

If you notice that the topic title isn't mentioned much while reading, don't be surprised. The key point of this article isn't about the title, but rather the problem-solving concepts we'll present.

Problem

When working with poorly designed code, such as unrelated code being coupled together, it makes code reusability difficult.

js | file-a.js
1
// Assumed to be setting up something important but unrelated
2
console.log(injectedVariable)
This line runs when require/import even though it's not needed
3
4
const obj = {
5
field: 'interesting value'
6
}
Value we want to use elsewhere
7
8
module.exports = obj

When using file-a.js in other files, we need to set up various values before they can be used, like:

js | file-b.js
1
global.injectedVariable = "Injected Utils, Helpers, ..."
setup to be able to call file-a.js
2
3
const obj = require('./file-a.js')
4
console.log(obj.field)

Problems we'll encounter, like writing tests for file-a.js without setup because it's not related to the test

js | file-a.test.js
1
const obj = require('./file-a.js')
ReferenceError: injectedVariable is not defined
2
3
test('correct value', () => {
4
expect(obj.field).toBe('interesting value')
5
})

When running file-a.test.js, we'll get an error because we didn't set up injectedVariable first, even though the obj.field value we need doesn't depend on injectedVariable

Solutions

When encountering this situation, some might notice this code lacks proper Separation of Concerns (SoC). This happen when unrelated code is forced to run together instead of running independently.

Refactor

If we can refactor the code, we might change it to:

js | file-a.js
1
+
const setup = () => {
2
+
// Assumed to be setting up something important but unrelated
3
console.log(injectedVariable)
4
+
}
Wrap in function to control when it runs
5
6
const obj = {
7
field: 'interesting value'
8
}
9
10
module.exports = { setup, obj }
export to let the importing file choose when to run
js | file-b.js
1
global.injectedVariable = "Injected Utils, Helpers, ..."
2
3
const { setup, obj } = require('./file-a.js')
4
+
setup()
Files that need it can call setup
5
console.log(obj.field)
js | file-a.test.js
1
const { obj } = require('./file-a.js')
Files that don't need it don't have to call setup
2
3
test('correct value', () => {
4
expect(obj.field).toBe('interesting value')
5
})

Just like that, our code has better separation of concerns and can run tests.

If you think this code can be written better, try it as homework. Because this isn't our goal today.

When Refactor isn't an option

Let me share another perspective. What if refactoring is not an option we have, perhaps because:

  • We have 100 files like file-a.js
  • file-b.js is our core code structure that serves as foundation for hundreds of files
  • Almost no tests written - Legacy code with no subject matter experts
  • Boss won't allow changes for some reasons ¯\_(ツ)_/¯

We can't just refactor to access obj.field because we don't know the full impact, which parts will break, or even who will help us fix hundreds of files and approve our PR.

Anyone facing this situation encounters the chicken-and-egg problem - wanting to refactor first but lacking test coverage, yet needing to refactor before writing more tests.

Generated by DALL·E 3. Prompt "A realistically rendered image displaying the philosophical conundrum known as the 'chicken and egg' problem."

So let's step back. Our current goal is to extract obj.field. Besides importing normally like regular programming, what other approaches can we take?

Regex

Wouldn't it be nice if we could mimic human behavior - open VS Code, cmd+f search for "field", select the value in "" after :, then cmd+c? 🤔

Why not? We have Regex. Let's write some quick code:

js | extract-text-with-regex.js
1
const fs = require('fs')
2
3
const codeAsText = fs.readFileSync('./file-a.js', { encoding: 'utf-8' })
Read file-a.js as plain text
4
const regex = /field:\s*['"](.*?)['"]/;
Use regex to find desired pattern
5
const match = input.match(regex);
6
const output = match[1]
7
8
console.log(output); // Output: interesting value
Get desired result

EZ? If our code isn't complex or follows standards, this could be a quick and effective method. But when our code can be written in various ways like:

js
1
const obj = {
2
field:
3
'interesting value'
new lines
4
}
js
1
const obj = {
2
field : 'interesting value'
extra white space and didn't run prettier
3
}

These codes run like the original, but writing regex to cover all cases wouldn't be easy or practical since comprehensive regex could become too hard to read. So we'll stop here with regex.

Abstract Syntax Tree (AST)

If we can't run the code directly, and reading as plain text is too complex for the time invested since we'd basically be writing our own parser from scratch, what middle ground options do we have?

Today I'm introducing AST. It might sound scary at first, which is true, but understanding just a little lets us use it effectively.

The cool thing is that instead of dealing with a wall of text, we can handle code in chunks related by their function. Each chunk becomes a Node by type - like code that runs a function without storing the result becomes an Expression Statement, while variable declarations become Variable Declaration nodes. We don't need to worry about whether lines end with semicolons or handle different syntaxes that produce the same result - we can focus on the functionality instead.

For example, in file-a.js, when run through a parser it can be divided into 3 main chunks:

js | file-a.js
1
console.log(injectedVariable)
2
3
const obj = {
4
field: 'interesting value'
5
}
6
7
module.exports = obj
mermaid | AST
Program
ExpressionStatement
VariableDeclaration
ExpressionStatement

From here, we have 2 main options:

  1. Extract obj.field by traversing object AST nodes - similar to regex but using programming language
  2. Remove unused code and keep remaining parts to run as normal code

Neither approach is wrong - just choose what fits the situation.

For today, we'll take approach #2 - removing unwanted code and running what remains.

Our mental model now is wanting this result:

js | modified-file-a.js
1
-
console.log(injectedVariable)
2
-
3
const obj = {
4
field: 'interesting value'
5
}
6
7
module.exports = obj
mermaid | AST
Program
VariableDeclaration
ExpressionStatement

To write code achieving this, we'll follow these steps:

js | custom-extractor-script.js
1
const { parse } = require('acorn')
lib for converting code to ast
2
const escodegen = require('escodegen')
lib for converting ast back to code
3
const transformAST = requrie('./transformAST')
assume this is function handling ast to get desired result
4
const codeAsText = fs.readFileSync('./file-a.js', { encoding: 'utf-8' })
read code as string
5
const ast = parse(codeAsText)
convert code to ast
6
const transformedAST = transformAST(ast)
transform ast by removing/modifying as needed, in this case removing line 1
7
const transformedCodeAsText = escodegen.generate(transformedAST)
convert ast back to code
8
const obj = eval(transformedCodeAsText)
run modified code like using require/import with eval
9
console.log(obj.field)
PRINT: interesting value

With this, we have a script like modified-file-a.js that can run without modifying or destroying original code.

Simple Example of transformAST(ast)

js | transformAST.js

Usage Example

For those wondering where the OpenAPI Spec Generator went, we saved it as a more complex example of today's problem.

Let's see why this code is difficult to use elsewhere:

js | router.js
1
-
const { controller } = application
application is injected, which isn't required but prevent generating API Schema
2
const { z } = require('zod')
3
4
const getPet = {
5
method: 'GET',
6
path: '/pet/:petID',
7
validation: {
8
param: {
9
petID: z.string().uuid(),
10
}
11
},
Context for Gen API Schema
12
-
controller: controller.getPet
Not required but prevent generating API Schema
13
}
14
15
const addPet = {
16
method: 'POST',
17
path: '/pet',
18
validation: {
19
body: {
20
petID: z.string().uuid().optional(),
21
name: z.string().uuid(),
22
status: z.enum(["available", "pending", "sold"]).optional(),
23
}
24
},
Context for Gen API Schema
25
-
controller: controller.addPet
Not required but prevent generating API Schema
26
}
27
28
module.exports = [getPet, addPet]

From the code, we can see several lines preventing us from easily require/import this file.

To use it, we apply the same approach as before, modifying custom-extractor-script.js especially the transformAST(ast) function to remove unused nodes.

When successful, we should get this result:

js | router.js
1
const { z } = require('zod')
2
3
const getPet = {
4
method: 'GET',
5
path: '/pet/:petID',
6
validation: {
7
param: {
8
petID: z.string().uuid(),
9
}
10
},
11
}
12
13
const addPet = {
14
method: 'POST',
15
path: '/pet',
16
validation: {
17
body: {
18
petID: z.string().uuid().optional(),
19
name: z.string().uuid(),
20
status: z.enum(["available", "pending", "sold"]).optional(),
21
}
22
},
23
}
24
25
module.exports = [getPet, addPet]

After running eval/import/require with our newly created code, we can use these values to generate OpenAPI Specification. Yay!

Conclusion

This article might not be friendly for everyone, but hopefully it opens up new perspectives on handling code in different situations - whether viewing code as regular code, text, or as node and tree structures. By keeping an open mind to new perspectives, we can handle challenging problems without them being out of reach.

See you in the next article~