Controllers
Controllers are typescript classes, with decorators to handle specific metadata such as routing, HTTP methods, and validation.
For every app, there is a main AppController
which simply holds a controllers
array.
We only require that you should have a named export AppController
from an index.ts
file.
A recommended controllers folder layout is:
src/
controllers/
index.ts <-- AppController is here
auth.ts <-- Auth
posts.ts <-- Posts
There is no need to suffix Controller
to every class, unless you feel it is more aesthetical (other than AppController
which every controllers
folder should have) or -controller
for every file name.
A controller walkthrough
We want to create an ergonomic balance: have enough magic but not too much that we overload with abstractions cognitive load, and have plenty of escape hatches for when you reach for power, because we can't ignore the system we're running on.
This is why you specify a route with a @Post
but have access to plain old Express Request
, and then still -- have helpers such as ok
and unauthorized
to save you some typing.
const requireLoginParams = requires(
z.object({
username: z.string().min(3).email(),
password: z.string().min(5),
})
)
const requireRegisterParams = requires(
requireLoginParams.extend({
name: z.string().min(2),
})
)
@Controller('auth')
export default class Auth {
@Post('login')
async login(req: Request) {
const { username, password } = requireLoginParams(req.body)
const user = await User.findOne({ where: { username } })
if (!user) {
throw unauthorized('incorrect username or password')
}
if (!(await user.verifyPassword(password))) {
throw unauthorized('incorrect username or password')
}
return ok({ token: user.createAuthenticationToken() })
}
Controllers and strong params
Using requires
is how to do strong parameters in Hyperstack. Strong parameters is the practice of explicitly filtering and receiving outside data into your model, which help block a class of attacks: if someone tries to overwrite a property such as ownerId
with a POST
update, you'll get an exception. No more blind updates.
Coding strong params
Why do we build requireParams separately and independent of everything else? clearly we could have automatically bound a route to these, and provided the data being posted magically as a statically typed object.
- You may want to organize all of your require schemas in a central place. Being an atomic unit helps with that.
- You may want to share schemas with your frontend, Zod is used there too. A login schema is a login schema both on backend and frontend. Sharing requires a format that's neutral of anything else (e.g. a backend request object)
- You can compose these and use them as a building block.
- You can generate your Zod schemas from any format and to any format. This can only be done if we keep the generated target or source constrained to pure Zod.
Lastly, if you ever want that kind of magic that goes over body data automatically with a schema, we provide it with the ParseBody
middleware, which at least ensures what ever is in your body, is there safely.
Note that requires
means the parameters you get back are not optional. If you still get optional fields (e.g. firstName?
), configure strict.
Request and Response
We give you the express request right there in req
. There's no point abstracting it because it's too useful and too much common knowledge is built around it.
However, we do help you build better responses. In Hyperstack there's no need to do this:
res.json({...})
Or fiddle with when to send status codes and terminating a response correctly.
A controller method can return any of:
- An object (it becomes a
200 OK
and JSON serialized out) - A response object, e.g.
HTTPResponseOK(...)
which turns into JSON and the appropriate status code, and you can throw some of these to signal an error, e.g.HTTPResponseUnauthorized(..)
. Errors are still response objects so that you can throw or return them as you prefer. - Some ergonomic methods such as
ok(..)
,unauthorized(..)
which actually are just a simple way to construct a response object.
Either way, you can reach out for power and grab an express response:
login(req, res){
//res is now available
}
For reference, here's what ok
and unauthorized
are actually doing:
const ok = (data) => new HttpResponseOK(data)
const unauthorized = (error) =>
new HttpResponseUnauthorized({
error,
})
Accepting data
Strong params
permits(<zod schema>)
import {
// ..
permits,
} from 'hyperstack'
const postParams = permits(<zod schema>)
const post = postParams(req.body)
Permits only the specified schema to pass, extra fields are filtered and missing fields are OK.
Use to block "id overwrite" attacks, where some endpoints blindly update complete objects and attackers try to fiddle with internal IDs. With strong params, you opt-in to data from the outside world.
Requiring params
import {
// ..
requires,
} from 'hyperstack'
const postParams = requires(<zod schema>)
const post = postParams(req.body)
Requires exactly the specified schema.
Like strong params, but signals that you need all the data that you've declared to continue to operate.
Configuration
Here's the most common controller configuration flags, in environments/[env name].ts
:
controllers: {
baseUrl: 'http://localhost:5150',
cookieSecret: 'shazam',
jwtSecret: 'sekret',
forceHttps: true,
gzip: true,
indexCatchAll: true, // serve from public/index.html
serveStatic: true, // serve static content from public/
// helmet?: bool | {..}
// json?: bool | {..}
// urlencoded?: bool | {..}
},
API Configuration
Controllers come preconfigured with secure defaults. To tweak these you can use:
helmet
- specify your custom helmet secure headers or disable it withfalse
. Don't specify it for defaults.json
- specify your custom json middleware configuration or disable it withfalse
. Don't specify it for defaults.urlencoded
- specify your custom url encoding middleware or disable it withfalse
. Don't specify it for defaults.
Testing
The test framework for testing controllers in Hyperstack is an impressive piece of gear. Actually, it's called a requests test, and the idea is that it tests everything from API down to database, so think of it more of an integration test.
We don't bother with isolating the API layer and testing it separately from the data layer.
A requests test will boot the app, wipe the database and bring it up to date and also set up a live API app for you, which is available through request()
.
Set up data, make a request, and snapshot.
import { test } from '@hyperstackjs/testing'
import { root } from '../../../config/settings'
import { appContext } from '../../../app'
const {
requests,
matchers: { matchRequestWithSnapshot },
} = test(root)
describe('requests', () => {
describe('/articles', () => {
requests('all', async (request) => {
const { Article } = appContext.models()
await Article.create({
title: 'string',
content: 'some text',
deleted: true,
})
await matchRequestWithSnapshot(200, request().get(`/articles`))
})
})
})