Currently queries and renders the normalized data from: https://api.openbrewerydb.org/breweries. This API was chosen because it was the first one I found that returns objects with snake_case
keys.
git clone git@github.com:ashleemboyer/camel-casing-apis.git
cd camel-casing-apis
yarn
yarn dev
I started this project because I wanted to try solving a common problem with a small solution that uses TypeScript, Jest, and GitHub workflows. One of my big focuses for the next few months is getting a better grasp of TypeScript. I'm very comfortable with the basics and debugging errors, but I want to learn how to use TypeScript in the most effective ways. I'll be spending a lot of time with two books in the coming weeks and learning out loud:
For the rest of this README I'll step through the commit history and detail all of the new things I learned and tripped on.
- 1. Set up a Next.js project with TypeScript
- 2. Adding an api utility
- 3. Adding utils for normalizing the casing
- 4. Updating api to use the new utils and type
- 5. Adding Jest tests
- 6. Running the tests from a GitHub workflow
I usually start new Next.js projects with their manual approach because doing things like this repetively helps me retain the information. So, I created a new directory a new directory called camel-casing-apis
, and installed the next
, react
, react-dom
, and sass
packages.
yarn add next react react-dom sass
Then I added some scripts to package.json
:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
}
}
After that, I added the initial files needed to run Next.js in development mode (next dev
).
pages/_app.tsx
import type { AppProps } from 'next/app';
import '@styles/global.scss';
const App = ({ Component, pageProps }: AppProps) => (
<Component {...pageProps} />
);
export default App;
pages/index.tsx
const HomePage = () => <h1>Hello from HomePage!</h1>;
export default HomePage;
styles/global.scss
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
At that point, I followed the Next.js docs about TypeScript to get next dev
working. First, I created an empty tsconfig.json
file.
touch tsconfig.json
Then I ran yarn dev
and found that I needed to install @types/react
and typescript
.
yarn add --dev typescript @types/react
Running yarn dev
again generates the tsconfig.json
. In there, I added the baseUrl and path aliasing I normally do in a jsconfig.json
file. The following is what allows me to import files from the styles
directory like I did in the pages/_app.ts
file (import '@styles/global.scss'
).
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@styles/*": ["styles/*"]
},
...
}
...
}
With this being everything I needed to get the app running in a browser, I added a .gitignore
file and started committing my work.
.next
node_modules
I knew I wanted to use axios
for making requests, so I added it.
yarn add axios
Then I added a utils
directory and aliasing for it.
{
"compilerOptions": {
"paths": {
"@styles/*": ["styles/*"],
"@utils/*": ["utils/*]
},
...
},
...
}
In the utils
directory, I added a starter api
util that exports a get
function for making GET
requests with axios
:
import axios from 'axios';
export const get = async (url: string, options?: Record<string, any>) => {
try {
const response = await axios.get(url, options);
return response;
} catch (error) {
console.error(error);
}
};
I wanted to render the result from this function on the main page (pages/index.tsx
), so I updated the component and added some styles for it.
pages/index.tsx
import { useEffect, useState } from 'react';
import { get } from 'utils/api';
import styles from '@styles/HomePage.module.scss';
const HomePage = () => {
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState();
useEffect(() => {
get('https://api.openbrewerydb.org/breweries').then((res) => {
setData(res.data);
setIsLoading(false);
});
});
if (isLoading) {
return <h1>Loading...</h1>;
}
return (
<div className={styles.HomePage}>
<h1>Data loaded!</h1>
<code>
<pre>{JSON.stringify(data, null, 2)}</pre>
</code>
</div>
);
};
export default HomePage;
styles/HomePage.module.scss
.HomePage {
padding: 24px;
h1 {
margin-bottom: 24px;
}
code {
pre {
background-color: rgba(black, 0.15);
border-radius: 4px;
padding: 16px;
font-size: 1rem;
}
}
}
The app looked like this after running yarn dev
(you can see that the object keys are snake_case
):
The main util
I added was called normalizeKeyCasing
. It needed to recursively look at objects and arrays within an object and convert all keys with snake_case
to camelCase
. My function looks at three cases:
- Is the given argument an array?
- Is the given argument an object?
- If it's not one of the previous two, we'll return it.
normalizeKeyCasing
import { capitalizeString, isArray, isObject } from '@utils';
const normalizeKeyCasing = (arg: any): any => {
let normalizedObject: any;
if (isArray(arg)) {
normalizedObject = arg.map((o: any) => normalizeKeyCasing(o));
} else if (isObject(arg)) {
normalizedObject = {};
for (const key in arg) {
const normalizedKey = key
.split('_')
.map((piece, index) => (index === 0 ? piece : capitalizeString(piece)))
.join('');
normalizedObject[normalizedKey] = arg[key]
? normalizeKeyCasing(arg[key])
: null;
}
} else {
normalizedObject = arg;
}
return normalizedObject;
};
export default normalizeKeyCasing;
So the next util
I added was isArray
. It's a wrapper for Array.isArray
:
utils/isArray
const isArray = (arg: any): boolean => Array.isArray(arg);
export default isArray;
Then I started working on the isObject
util. Something is an object (like the traditional JSON object if it's not an array, not a function, and its value doesn't change when cased with Object()
.
utils/isObject
import { isArray, isFunction } from '@utils';
const isObject = (arg: any): boolean =>
!isArray(arg) && !isFunction(arg) && arg === Object(arg);
export default isObject;
The util needed to make this function work is isFunction
. It checks the value of typeof
to be function
.
utils/isFunction
const isFunction = (arg: any): boolean => typeof arg === 'function';
export default isFunction;
The last util is capitalizeString
. It capitalizes the first character of a string.
utils/capitalizeString
const capitalizeString = (str: string): string => {
return str[0].toUpperCase() + str.slice(1);
};
export default capitalizeString;
At this point I also decided that I wanted to simplify the import statements for these utils, so I decided to export everything from the directory from an index.ts
file.
utils/index.ts
import api from './api';
import capitalizeString from './capitalizeString';
import isArray from './isArray';
import isFunction from './isFunction';
import isObject from './isObject';
import normalizeKeyCasing from './normalizeKeyCasing';
export {
api,
capitalizeString,
isArray,
isFunction,
isObject,
normalizeKeyCasing,
};
Now that the structure of the directory had been figured out, I could add the path aliasing for this directory in tsconfig.json
. I also decided to add a customTypes
directory and add a file for apiTypes
.
tsconfig.json
{
"compilerOptions": {
"paths": {
"@customTypes/*": ["customTypes/*"],
"@styles/*": ["styles/*"],
"@utils": ["utils"]
},
...
},
...
}
customTypes/apiTypes.ts
export interface ApiResponse {
data: any;
}
This is how the api
ended up with the new exporting approach for the utils
directory, the new utils, and handling the catch
block a little more nicely.
utils/api.ts
import axios from 'axios';
import { ApiResponse } from '@customTypes/apiTypes';
import { normalizeKeyCasing } from '@utils';
const get = async (
url: string,
options?: Record<string, any>,
): Promise<ApiResponse> => {
try {
const response = await axios.get(url, options);
return {
data: normalizeKeyCasing(response.data) || {},
};
} catch (error) {
console.error(error);
return {
data: null,
};
}
};
export default { get };
I also needed to update pages/index.tsx
to import the api
utility differently and use the new ApiResponse
type.
pages/index.tsx
import { useEffect, useState } from 'react';
import { ApiResponse } from '@customTypes/apiTypes';
import { api } from '@utils';
import styles from '@styles/HomePage.module.scss';
const HomePage = () => {
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState<ApiResponse>();
useEffect(() => {
api
.get('https://api.openbrewerydb.org/breweries')
.then((res: ApiResponse) => {
setData(res.data);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <h1>Loading...</h1>;
}
return (
<div className={styles.HomePage}>
<h1>Data loaded!</h1>
<code>
<pre>{JSON.stringify(data, null, 2)}</pre>
</code>
</div>
);
};
export default HomePage;
After running the app again, the object keys in the data are now camelCase
instead of snake_case
. The view is the same as before other than that:
To configure Jest to work with the project, I first had to install a few devDependencies
and add a script to package.json
:
yarn add --dev jest @types/jest
package.json
{
"scripts": {
"test:unit": "jest --testPathPattern=unit.test.ts$"
},
...
}
The script I added is specific to the naming style I want to use for test files. Unit tests have a unit.test.ts
suffix and integration tests would have a int.test.ts
suffix.
A jest.config.js
file needed to be added so that the tests can use the same path aliasing as the rest of the app.
jest.config.js
module.exports = {
moduleNameMapper: {
'@utils': '<rootDir>/utils',
},
};
The tsconfig.json
file also had to be updated to include the new "jest.config.js"
file name at the end of the "include"
property's array.
After this, I added all of the test files for each utility (except api
since it uses a util I'm writing tests for and it also only uses axios
).
My first approach to the GitHub workflow used npm
to run tests. I'm not sure if that was part of the issue with that approach or if I was missing actions/setup-node@v1
, but I changed both. In my second approach, I commented out the unit-tests
job to see if using adding that previous mentioned action and using yarn
instead of npm
would help.
I then took the same approach in the unit-tests job and uncommented it. Everything worked great! I found that there's a fun badge for show CI tests, so I added that to the README and added some initial details. That's the whole story!