├── .gitignore ├── README.md ├── api ├── .gitignore ├── Makefile └── swagger.yml ├── components ├── Common │ ├── components │ │ ├── Page.test.tsx │ │ └── Page.tsx │ ├── index.ts │ └── rehydrateGlamor.ts └── User │ ├── components │ └── Profile │ │ ├── components │ │ ├── FriendsList.test.tsx │ │ ├── FriendsList.tsx │ │ ├── Header.tsx │ │ ├── Profile.test.tsx │ │ └── Profile.tsx │ │ └── index.ts │ └── index.ts ├── global.d.ts ├── json.d.ts ├── lib ├── api │ └── Client.ts ├── app.ts └── user │ ├── FakeUserGateway.ts │ ├── HTTPUserGateway.test.ts │ ├── HTTPUserGateway.ts │ ├── LoadUserProfile.test.ts │ ├── LoadUserProfile.ts │ ├── User.ts │ ├── UserGateway.ts │ └── index.ts ├── package-lock.json ├── package.json ├── pages ├── _document.tsx ├── home │ └── index.tsx └── users │ └── profile.tsx ├── react.d.ts ├── test ├── acceptance │ ├── home │ │ └── welcomeFolk.test.tsx │ └── users │ │ └── viewingProfile.test.tsx └── support │ ├── enzyme.ts │ ├── index.ts │ ├── jestEnzyme.ts │ ├── mockExecute.ts │ └── raf.ts ├── tsconfig.json └── tsconfig.production.json /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | .next 3 | node_modules 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript Next.js Clean Architecture 2 | 3 | A mouthful I agree! Pretty cool though. 4 | 5 | **Install:** 6 | 7 | ``` 8 | npm install 9 | ``` 10 | 11 | **Run development server:** 12 | 13 | ``` 14 | npm run dev 15 | ``` 16 | 17 | **Run tests:** 18 | 19 | With development server running in another tab: 20 | 21 | ``` 22 | npm test 23 | ``` 24 | 25 | Or watch your tests: 26 | 27 | ``` 28 | npm test -- --watch 29 | ``` 30 | 31 | **Run in production mode:** 32 | 33 | ``` 34 | npm run prepare 35 | npm start 36 | ``` 37 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | .swagger-codegen* 2 | swagger.json 3 | mock 4 | README.md 5 | -------------------------------------------------------------------------------- /api/Makefile: -------------------------------------------------------------------------------- 1 | all: install start 2 | 3 | install: 4 | swagger-codegen generate -i swagger.yml -l swagger 5 | swagger-codegen generate -i swagger.yml -l nodejs-server -o mock 6 | 7 | start: 8 | cd mock && npm start 9 | -------------------------------------------------------------------------------- /api/swagger.yml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: "0.0.1" 4 | title: Hello World App 5 | basePath: /api 6 | schemes: 7 | - http 8 | - https 9 | consumes: 10 | - application/json 11 | produces: 12 | - application/json 13 | paths: 14 | /users/{userId}: 15 | get: 16 | operationId: findUserById 17 | description: Returns user object 18 | parameters: 19 | - name: userId 20 | in: path 21 | description: The ID of the user 22 | required: true 23 | type: string 24 | responses: 25 | "200": 26 | description: Success 27 | schema: 28 | $ref: "#/definitions/User" 29 | default: 30 | description: Error 31 | schema: 32 | $ref: "#/definitions/Error" 33 | definitions: 34 | UserBasicInfo: 35 | properties: 36 | name: 37 | type: string 38 | example: "Mr Bob Example" 39 | biography: 40 | type: string 41 | example: "I like bicycles" 42 | twitter: 43 | type: string 44 | example: "@bobexample" 45 | createdAt: 46 | type: string 47 | format: date-time 48 | lastSeenAt: 49 | type: string 50 | format: date-time 51 | 52 | User: 53 | properties: 54 | basicInfo: 55 | $ref: "#/definitions/UserBasicInfo" 56 | friends: 57 | type: array 58 | items: 59 | $ref: "#/definitions/UserBasicInfo" 60 | example: 61 | - name: James 62 | biography: Like Bond 63 | createdAt: 2017-11-12T22:27:35.278Z 64 | lastSeenAt: 2017-11-12T22:27:35.278Z 65 | - name: Dan 66 | biography: Yeah mayne 67 | twitter: "@danmayne" 68 | createdAt: 2017-11-12T22:27:35.278Z 69 | lastSeenAt: 2017-11-12T22:27:35.278Z 70 | 71 | Error: 72 | required: 73 | - message 74 | properties: 75 | message: 76 | type: string 77 | example: "An example error occurred" 78 | -------------------------------------------------------------------------------- /components/Common/components/Page.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { mount } from 'enzyme' 3 | import Page from './Page' 4 | 5 | describe('', () => { 6 | it('should use children', () => { 7 | const page = mount(Cool) 8 | expect(page).toIncludeText('Cool') 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /components/Common/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import '../rehydrateGlamor' 3 | import Head from 'next/head' 4 | import { Main } from 'glamorous' 5 | 6 | interface PageProps { 7 | title: string 8 | children: any 9 | } 10 | 11 | export default ({ title, children }: PageProps) => 12 |
13 | 14 | {title} 15 | 16 | 17 | {children} 18 |
19 | -------------------------------------------------------------------------------- /components/Common/index.ts: -------------------------------------------------------------------------------- 1 | import Page from './components/Page' 2 | 3 | export { Page } 4 | -------------------------------------------------------------------------------- /components/Common/rehydrateGlamor.ts: -------------------------------------------------------------------------------- 1 | import { rehydrate } from 'glamor' 2 | 3 | if (typeof window !== 'undefined' && window.__NEXT_DATA__) { 4 | rehydrate(window.__NEXT_DATA__.ids) 5 | } 6 | -------------------------------------------------------------------------------- /components/User/components/Profile/components/FriendsList.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { shallow } from 'enzyme' 3 | import FriendsList from './FriendsList' 4 | 5 | describe('FriendsList', () => { 6 | describe('when there are friends', () => { 7 | const friends = [ 8 | { name: 'Bob' }, 9 | { name: 'Frankie' } 10 | ] 11 | 12 | it('should list friends', () => { 13 | const friendsList = shallow() 14 | expect(friendsList).toIncludeText('Bob') 15 | expect(friendsList).toIncludeText('Frankie') 16 | }) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /components/User/components/Profile/components/FriendsList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | interface Friend { 4 | name: string 5 | } 6 | 7 | interface FriendsListProps { 8 | friends: Array 9 | } 10 | 11 | export default ({ friends }: FriendsListProps) => 12 |
    13 | {friends.map(({ name }, i) =>
  • {name}
  • )} 14 |
15 | -------------------------------------------------------------------------------- /components/User/components/Profile/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import glamorous from 'glamorous' 3 | 4 | const Header = glamorous.header({ 5 | fontSize: '1.5em' 6 | }) 7 | 8 | const Name = glamorous.div({ 9 | fontSize: '2em' 10 | }) 11 | 12 | const Bio = glamorous.p() 13 | 14 | const Twitter = glamorous.p() 15 | 16 | interface HeaderProps { 17 | name: string 18 | biography: string 19 | twitter: string 20 | } 21 | 22 | export default ({ name, biography, twitter }: HeaderProps) => 23 |
24 | {name} 25 | {biography} 26 | Follow on twitter: {twitter} 27 |
28 | -------------------------------------------------------------------------------- /components/User/components/Profile/components/Profile.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { shallow } from 'enzyme' 3 | import Profile from './Profile' 4 | import Header from './Header' 5 | import FriendsList from './FriendsList' 6 | 7 | describe('', () => { 8 | const props = { 9 | name: 'Luke', 10 | biography: 'My bio', 11 | twitter: '@Cool', 12 | friends: [ 13 | { name: 'Jenny' } 14 | ] 15 | } 16 | 17 | it('should display header', () => { 18 | const profile = shallow() 19 | expect(profile.find(Header)).toBePresent() 20 | }) 21 | 22 | it('should display friends', () => { 23 | const profile = shallow() 24 | expect(profile.find(FriendsList)).toBePresent() 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /components/User/components/Profile/components/Profile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import glamorous from 'glamorous' 3 | import Header from './Header' 4 | import FriendsList from './FriendsList' 5 | 6 | const Profile = glamorous.div() 7 | 8 | interface ProfileProps { 9 | name: string 10 | biography: string 11 | twitter: string 12 | friends: any 13 | } 14 | 15 | export default ({ name, biography, twitter, friends }: ProfileProps) => 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /components/User/components/Profile/index.ts: -------------------------------------------------------------------------------- 1 | import Profile from './components/Profile' 2 | 3 | export { Profile } 4 | -------------------------------------------------------------------------------- /components/User/index.ts: -------------------------------------------------------------------------------- 1 | import { Profile } from './components/Profile/index' 2 | 3 | export { Profile } 4 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | __NEXT_DATA__: any 3 | } 4 | -------------------------------------------------------------------------------- /json.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.json" { 2 | const value: any 3 | export default value 4 | } 5 | -------------------------------------------------------------------------------- /lib/api/Client.ts: -------------------------------------------------------------------------------- 1 | import * as Swagger from 'swagger-client' 2 | import * as spec from '../../api/swagger.json' 3 | 4 | const API_HOST = process.env.API_HOST 5 | 6 | export function loadSpec (): any { 7 | return { ...spec, host: API_HOST } 8 | } 9 | 10 | export async function execute (operationId: string, parameters: object): Promise { 11 | const spec = loadSpec() 12 | 13 | try { 14 | const { body } = await Swagger.execute({ spec, operationId, parameters }) 15 | return body 16 | } catch (e) { 17 | if (e.status === 404) { 18 | console.info(e.message, operationId, parameters) 19 | } else { 20 | throw e 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/app.ts: -------------------------------------------------------------------------------- 1 | import Republic, { route } from 'republic/next' 2 | import { LoadUserProfile } from './user/' 3 | 4 | export default new Republic( 5 | route.page('/', 'home#index'), 6 | route.page('/:userId', 'users#profile', async ({ params }) => { 7 | return await LoadUserProfile({ userId: params.userId }) 8 | }) 9 | ) 10 | -------------------------------------------------------------------------------- /lib/user/FakeUserGateway.ts: -------------------------------------------------------------------------------- 1 | import User from './User' 2 | 3 | export async function findById (id: string) : Promise { 4 | return { 5 | name: 'Mr Luke Fake', 6 | email: 'luke@example.com', 7 | biography: 'Coool', 8 | twitter: '@Cool', 9 | friends: [] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/user/HTTPUserGateway.test.ts: -------------------------------------------------------------------------------- 1 | import * as HTTPUserGateway from './HTTPUserGateway' 2 | import mockExecute from '../../test/support/mockExecute' 3 | 4 | describe('HTTPUserGateway', () => { 5 | const fakeUser = { 6 | basicInfo: { name: 'Jim', email: 'jim@example.com' }, 7 | friends: [] 8 | } 9 | 10 | describe('when finding by id', () => { 11 | describe('and user exists', () => { 12 | let user 13 | 14 | beforeEach(async () => { 15 | mockExecute('findUserById', { userId: 'guid' }).reply(200, fakeUser) 16 | user = await HTTPUserGateway.findById('guid') 17 | }) 18 | 19 | test('user has name', () => { 20 | expect(user.name).toEqual('Jim') 21 | }) 22 | 23 | test('user has email', () => { 24 | expect(user.email).toEqual('jim@example.com') 25 | }) 26 | 27 | test('user has friends', () => { 28 | expect(user.friends).toEqual([]) 29 | }) 30 | }) 31 | 32 | describe('and user does not exist', () => { 33 | test('nothing is returned', async () => { 34 | mockExecute('findUserById', { userId: 'not-found' }).reply(404) 35 | const user = await HTTPUserGateway.findById('not-found') 36 | expect(user).not.toBeDefined() 37 | }) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /lib/user/HTTPUserGateway.ts: -------------------------------------------------------------------------------- 1 | import { execute } from '../api/Client' 2 | import User from './User' 3 | 4 | const API_ORIGIN = process.env.API_ORIGIN 5 | 6 | export async function findById (userId: string) : Promise { 7 | const user = await execute('findUserById', { userId }) 8 | 9 | if (user) { 10 | return { ...user.basicInfo, friends: user.friends } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/user/LoadUserProfile.test.ts: -------------------------------------------------------------------------------- 1 | import LoadUserProfile from './LoadUserProfile' 2 | import * as FakeUserGateway from './FakeUserGateway' 3 | 4 | describe('LoadUserProfile', () => { 5 | describe('when loading profile by id', () => { 6 | test('user has name', async () => { 7 | const { user } = await LoadUserProfile(FakeUserGateway, { userId: 'guid' }) 8 | expect(user.name).toBe('Mr Luke Fake') 9 | }) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /lib/user/LoadUserProfile.ts: -------------------------------------------------------------------------------- 1 | import UserGateway from './UserGateway' 2 | import User from './User' 3 | 4 | type Request = { 5 | userId: string 6 | } 7 | 8 | type Response = { 9 | user: User 10 | } 11 | 12 | export default async function LoadUserProfile (gateway: UserGateway, req: Request): Promise { 13 | const user = await gateway.findById(req.userId) 14 | return { user } 15 | } 16 | -------------------------------------------------------------------------------- /lib/user/User.ts: -------------------------------------------------------------------------------- 1 | export default interface User { 2 | name: string 3 | email: string 4 | biography: string 5 | twitter: string, 6 | friends: Array 7 | } 8 | -------------------------------------------------------------------------------- /lib/user/UserGateway.ts: -------------------------------------------------------------------------------- 1 | import User from './User' 2 | 3 | export default interface UserGateway { 4 | findById (string): Promise 5 | } 6 | -------------------------------------------------------------------------------- /lib/user/index.ts: -------------------------------------------------------------------------------- 1 | import * as partial from 'lodash.partial' 2 | 3 | import * as HTTPUserGateway from './HTTPUserGateway' 4 | 5 | import _LoadUserProfile from './LoadUserProfile' 6 | export const LoadUserProfile = partial(_LoadUserProfile, HTTPUserGateway) 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build:ts": "tsc -p tsconfig.production.json", 4 | "build:next": "API_HOST=localhost:8080 next build", 5 | "prepare": "npm run clean && npm run build:ts && npm run build:next", 6 | "start": "NODE_ENV=production API_HOST=localhost:8080 republic lib/app.js", 7 | "dev": "tsc && NODE_ENV=development API_HOST=localhost:8080 nodemon lib/app.js --exec republic --watch lib/", 8 | "api": "cd api && make", 9 | "test": "API_HOST=api jest --notify", 10 | "clean": "rm -f {components,lib,pages,spec}/{*,*/*,*/*/*,*/*/*/*,*/*/*/*/*}.js" 11 | }, 12 | "dependencies": { 13 | "@types/isomorphic-fetch": "0.0.34", 14 | "@types/react": "^16.0", 15 | "body-parser": "^1.18.2", 16 | "express": "^4.16.2", 17 | "glamor": "^2.20.40", 18 | "glamorous": "^4.11.0", 19 | "js-yaml": "^3.10.0", 20 | "lodash.partial": "^4.2.1", 21 | "next": "^4.1.2", 22 | "react": "^16.0", 23 | "react-dom": "^16.0", 24 | "republic": "^0.4.15", 25 | "swagger-client": "^3.3.3" 26 | }, 27 | "devDependencies": { 28 | "@types/enzyme": "^3.1.4", 29 | "@types/jest": "^21.1.6", 30 | "enzyme": "^3.1.1", 31 | "enzyme-adapter-react-16": "^1.0.4", 32 | "jest": "^21.0", 33 | "jest-enzyme": "^4.0.1", 34 | "nock": "^9.0.14", 35 | "nodemon": "^1.12.1", 36 | "react-test-renderer": "^16.0", 37 | "swagger": "^0.7.5", 38 | "ts-jest": "^21.1.4", 39 | "typescript": "^2.1.5" 40 | }, 41 | "jest": { 42 | "setupTestFrameworkScriptFile": "./test/support/index.ts", 43 | "transform": { 44 | "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js" 45 | }, 46 | "testRegex": "^.+\\.test\\.ts", 47 | "moduleFileExtensions": [ 48 | "tsx", 49 | "ts", 50 | "js" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Document, { Head, Main, NextScript } from 'next/document' 3 | import { renderStatic } from 'glamor/server' 4 | 5 | export default class MyDocument extends Document { 6 | static async getInitialProps ({ renderPage }) { 7 | const page = renderPage() 8 | const styles = renderStatic(() => page.html) 9 | return { ...page, ...styles } 10 | } 11 | 12 | props: { 13 | __NEXT_DATA__: any, 14 | ids: any, 15 | css: any 16 | } 17 | 18 | constructor (props) { 19 | super(props) 20 | 21 | if (this.props.ids) { 22 | this.props.__NEXT_DATA__.ids = this.props.ids 23 | } 24 | } 25 | 26 | render () { 27 | return ( 28 | 29 | 30 |