Authorising Requests in Nest.js with Neo4j
This post is the third in a series of blog posts that accompany the Twitch stream on the Neo4j Twitch channel where I build an application on top of Neo4j with NestJS.
This article assumes some prior knowledge of Neo4j and Nest.js. If you haven’t already done so, you can read the first two articles at:
Authorisation
Previously, we looked at authentication; how we could verify that the user is who they say they are. Now, we’ll look at ensuring that a user is actually able to do what they want — in other words authorisation. To clarify the difference, let’s take a look at a scenario for Neoflix.
When a user first registers for Neoflix they should automatically be given a 30 day free trial, which will give them access to all content on the site. After this free trial ends, they will be required to buy a subscription, which automatically renews every 30 days, unless the user cancels the subscription.
The fact that the user has valid user credentials (in our case email and password) means that the API can correctly authenticate them. Although they have valid credentials, the absence of a current subscription means that we can’t authorise their request. Authorisation may also extend to users who are under 18 years old, but are trying to access adult content.
Adding Subscriptions to the Database
To build authorisation into the API, we’ll first need to add nodes to represent packages and subscriptions to the database.
Our :Package
nodes will be uniquely identified by an ID, so the first thing to do is to create the constraint in the database. Subscriptions will also be created with a unique ID so we can also create the constraint on :Subscription
nodes.
CREATE CONSTRAINT ON (p:Package) ASSERT p.id IS UNIQUE;
CREATE CONSTRAINT ON (s:Subscription) ASSERT s.id IS UNIQUE;
I have created a CSV file with six packages, each of which has a unique ID (integer
), name, price (float
) and the number of days that a package is valid for for that price. Similar to Sky Movies subscriptions, packages will provide access to one or more genres - these are represented in the CSV file as a pipe delimited list of the genres.
head -n 3 data/packages.csvid,name,price,days,genres
1,Childrens,4.99,30,Animation|Comedy|Family|Adventure
2,Bronze,7.99,30,Animation|Comedy|Family|Adventure|Fantasy|Romance|Drama
This CSV file can be imported using LOAD CSV
. As mentioned in the session on Modelling, all data loaded by LOAD CSV
is cast as a string by default, so we'll have to convert the package to an integer using toInteger
and the price into a float using toFloat()
. We can convert the days column into a native Neo4j duration using a string in a format recognised by a Java Duration
. In this case we're interested in the number of days, for example P{X}D
where {X}
is the number of days.
LOAD CSV WITH HEADERS FROM 'file:///packages.csv' AS row
MERGE (p:Package {id: toInteger(row.id)})
SET p.name = row.name,
p.duration = duration('P'+ row.days +'D'),
p.price = toFloat(row.price)FOREACH (name IN split(row.genres, '|') |
MERGE (g:Genre {name: name})
MERGE (p)-[:PROVIDES_ACCESS_TO]->(g)
)
The FOREACH
statement at the end of the query splits the genres column by pipe (|
), finds or creates the :Genre
node on its name, and then creates a relationship to the package; signifying that a valid subscription for this Package provides access to :Movie
within the :Genre
.
Checking Subscriptions using Cypher
To show the power of querying this data as a graph, we can also provide access to movies produced by a certain production company. For example, the bronze package provides access to films in the genres of Animation|Comedy|Family|Adventure|Fantasy|Romance|Drama
. We can traverse from the :User
node, through the :Subscription
and :Package
, to the :Genre
, all in real-time. A user will be able to access any :Movie
node with a relationship.
(:User)-[:HAS_SUBSCRIPTION]->(:Subscription {expiresAt: datetime})
-[:FOR_PACKAGE]->(:Package)
-[:PROVIDES_ACCESS_TO]->(:Genre)<-[:IN_GENRE]-(:Movie)
Cypher allows us to be flexible. Say we also want to grant access to videos produced by certain :ProductionCompany
nodes. We can create a relationship type with the same name, and remove the label check on the node between the :Package
and :Movie
, and add another relationship type.
(:Package)-[:PROVIDES_ACCESS_TO]->( ??? )<-[:IN_GENRE|PRODUCED_BY]-(:Movie)
So, let’s find some production companies to provide additional access too:
MATCH (p:ProductionCompany)<-[:PRODUCED_BY]-(m)-[:IN_GENRE]->(g)
WITH p, collect(distinct g.name) AS genres, count(distinct m) AS movies
WHERE none(g in genres WHERE g in split("Animation|Comedy|Family|Adventure|Fantasy|Romance|Drama", "|"))
RETURN * LIMIT 10
The companies ‘Bluehorse Films’ (id: 93174
), ‘InPictures’ (id: 12912
) and ‘Ciak Filmproduktion’ (id: 83201
) seem like good options; they have all produced a single film listed as Documentary
. We can use the IN
predicate to find the production companies by their IDs, and create a new PROVIDES_ACCESS_TO
relationship between the bronze package and the production company.
MATCH (p:Package {id: 2})
MATCH (c:ProductionCompany)
WHERE m.id IN [93174, 12912, 83201]
CREATE (p)-[:PROVIDES_ACCESS_TO]->(c)
If we create a test user, we can demonstrate how to check the user is allowed access to a :Movie:
MATCH (p:Package {id: 2})
CREATE (u:User {
id: 'test',
email: 'bronze.user@neoflix.com',
firstName: 'Test',
lastName: 'User'
})
CREATE (u)-[:HAS_SUBSCRIPTION]->(s:Subscription {
expiresAt: datetime() + duration('P2D')
})-[:FOR_PACKAGE]->(p)
The :Subscription
node has an expiresAt
property which contains a Neo4j datetime
- for the subscription to be active, that date should be greater than the current date and time (s.expiresAt >= datetime()
).
MATCH (u:User {id: 'test'})-[:HAS_SUBSCRIPTION]->(s)-[:FOR_PACKAGE]->(p)-[:PROVIDES_ACCESS_TO]->(g)<-[:IN_GENRE]-(m)
WHERE s.expiresAt > datetime()
RETURN * LIMIT 10
Given a few films selected at random, we can check the path between the user and the movie, to check whether the user has access from a valid subscription:
MATCH (u:User {id: 'test'})-[:HAS_SUBSCRIPTION]->(s)-[:FOR_PACKAGE]->(p)
WHERE s.expiresAt >= datetime()MATCH (m:Movie)
WITH u, s, p, m ORDER BY rand() LIMIT 10RETURN
m.id,
m.title,
exists((m)-[:IN_GENRE|PRODUCED_BY]->()<-[:PROVIDES_ACCESS_TO]-(p)) AS canAccess╒══════╤════════════════════════════╤═══════════╕
│"m.id"│"m.title" │"canAccess"│
╞══════╪════════════════════════════╪═══════════╡
│31527 │"The Scarlet Empress" │true │
├──────┼────────────────────────────┼───────────┤
│4988 │"Semi-Tough" │true │
├──────┼────────────────────────────┼───────────┤
│12623 │"Suspect" │true │
├──────┼────────────────────────────┼───────────┤
│11896 │"Throw Momma from the Train"│true │
├──────┼────────────────────────────┼───────────┤
│25538 │"Yi Yi" │true │
├──────┼────────────────────────────┼───────────┤
│17971 │"Midnight Madness" │true │
├──────┼────────────────────────────┼───────────┤
│6498 │"Nightwatch" │false │
├──────┼────────────────────────────┼───────────┤
│5923 │"The Sand Pebbles" │true │
├──────┼────────────────────────────┼───────────┤
│15497 │"Twelve O'Clock High" │true │
├──────┼────────────────────────────┼───────────┤
│21876 │"Von Ryan's Express" │true │
└──────┴────────────────────────────┴───────────┘
Adding Subscriptions to the API
To add subscription functionality to the API, we’ll need to create a subscription module and service. We’ll do this using the Nest CLI:
nest g mo subscription # or nest generate module subscription
nest g s subscription # or nest generate service subscription
Inside the SubscriptionService
, we should create a method to make a new subscription for the user called createSubscription
. As the user will always exist and the node already assigned to the request by the JwtAuthGuard
, we can pass this through as the first parameter. To save the extra database query, we'll pass through the package ID as a number, as this will most likely be passed as part of the request body. Optionally, we can add a parameter to override the number of days until the subscription expires. If that number isn't provided, we'll fall back to the duration property on the :Package
node.
// subscription.service.ts
export type Subscription = Node
// ...
async createSubscription(user: User, packageId: number, days: number = null): Promise<Subscription> {
// ...
}
For the Cypher query itself, we want to find the user and package by their ID’s, then create a :Subscription
node. The subscription node should have it's own ID (generated with cypher's randomUUID
function), a createdAt
datetime, and also the expiration date and time.
const userId = (<Record<string, any>> user.properties).id
const res = await this.neo4jService.write(`
MATCH (u:User {id: $userId})
MATCH (p:Package {id: $packageId})
CREATE (u)-[:PURCHASED]->(s:Subscription {
id: randomUUID(),
createdAt: datetime(),
expiresAt: datetime() +
CASE WHEN $days IS NOT NULL
THEN duration('P'+ $days +'D')
ELSE p.duration END
})-[:FOR_PACKAGE]->(p)
RETURN s
`, { userId, packageId: this.neo4jService.int(packageId), days })return res.records[0].get('s')
Note: Working with Integers
So far we’ve not worked with integers in Neo4j, so the this.neo4jService.int
function will need some explaining. The Neo4j type system uses 64bit integers with a max value of 922337203685477600
- considerably higher than the maximum value that JavaScript can safely represent as an integer (Number.MIN_SAFE_INTEGER
or 9007199254740991
). For this reason, the Neo4j driver comes with it's own Integer
value (exported under neo4j.types.Integer
). Any number that isn't explicitly converted to this integer type will be passed to the driver as a Float
.
For this reason, we’ll either need to explicitly convert our integers to this Integer
type using the int
function provided by the driver, or use the toInteger
function in Cypher to convert the number back from a float.
For this reason, I’ve added a method to the Neo4j service which will uses the int
function exported from the Neo4j Driver.
// neo4j.service.ts
import { int } from 'neo4j-driver'// ...toInteger(value: number) {
return int(value)
}
More on integers with the Neo4j Driver
Using the SubscriptionService within the AuthModule
To use the new subscription service, we need to add the SubscriptionModule
to the imports for the AuthModule
:
// subscription.module.ts
import { SubscriptionModule } from '../subscription/subscription.module';// ...
@Module({
imports: [
JwtModule.registerAsync({
imports: [ ConfigModule, ],
inject: [ ConfigService ],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_EXPIRES_IN'),
},
})
}),
UserModule,
EncryptionModule,
SubscriptionModule, // <-- new module
],
providers: [AuthService, LocalStrategy, JwtStrategy],
controllers: [AuthController]
})
export class AuthModule {}
Then we can inject the SubscriptionService
into the AuthController
.
// auth.controller.ts
import { SubscriptionService } from '../subscription/subscription.service';// ...
export class AuthController { constructor(
private readonly userService: UserService,
private readonly authService: AuthService,
private readonly subscriptionService: SubscriptionService
) { } // ...
}
Creating a new “Free Trial” Package
We’ll also need to create a “Free Trial” package to automatically subscribe new customers. Let’s go ahead and create a new Package with an ID of 0
, so that it can be easily found. We’ll give it a price of 0.00
, and a default duration of 30 days.
To give the user the best experience, we'll also create :PROVIDES_ACCESS_TO
relationships to each of the genre nodes.
CREATE (p:Package {
id: 0,
name: "Free Trial",
price: 0.00,
duration: duration('P30D')
})
WITH p
MATCH (g:Genre)
CREATE (p)-[:PROVIDES_ACCESS_TO]->(g)
Then, in the postRegister
route handler, we can add the call to the new createSubscription
method on the subscription service.
// auth.controller.ts
@Post('register')
async postRegister(@Body() createUserDto: CreateUserDto) {
const user = await this.userService.create(
createUserDto.email,
createUserDto.password,
new Date(createUserDto.dateOfBirth),
createUserDto.firstName,
createUserDto.lastName
) // Create a free subscription
await this.subscriptionService.createSubscription(user, 0) return await this.authService.createToken(user)
}
Note: Currently this executes two separate database transactions. That’s fine for small workloads, but these two operations should really take place within the same database transaction. We’ll sort that out at a later date.
Testing the new Subscription Module
We’ve not introduced any breaking changes so running the end-to-end tests should still pass:
npm run test:e2e> api@0.0.1 test:e2e /Users/adam/projects/twitch/api
> jest --detectOpenHandles --config ./test/jest-e2e.json PASS test/app.e2e-spec.ts
AppController (e2e)
Auth
POST /auth/register
✓ should validate the request (238 ms)
✓ should return HTTP 200 successful on successful registration (165 ms)
POST /auth/login
✓ should return 401 if username does not exist (41 ms)
✓ should return 401 if password is incorrect (96 ms)
✓ should return 201 if username and password are correct (89 ms)
GET /auth/user
✓ should return unauthorised if no token is provided (30 ms)
✓ should return unauthorised on incorrect token (25 ms)
✓ should authenticate a user with the JWT token (30 ms)Test Suites: 1 passed, 1 total
Tests: 8 passed, 8 total
Snapshots: 0 total
Time: 1.834 s, estimated 2 s
If we check in the database, there should now also be a :Subscription
node connected to the newly created :Package
node.
MATCH (p:Package {id: 0})
RETURN size((p)<-[:PURCHASED]-()) // 1
Getting Genres from a User’s Subscriptions
Now that we have a subscription node related to a user, we can use the graph to provide a list of genres that they have access to. Let’s create a genre module, service and controller to take care of this.
nest g mo genre
nest g s genre
nest g co genre
In the GenreController
, we'll want to create a route handler to listen for GET
requests to the genres/
endpoint and return a list of genres. The route will be guarded by the JwtAuthGuard
to ensure that the user is logged in. We can then use the @Request
decorator to inject the request object into the method, where we can get the user
node.
// genre.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { GenreService } from './genre.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';@Controller('genres')
export class GenreController { constructor(private readonly genreService: GenreService) {} @UseGuards(JwtAuthGuard)
@Get('/')
async getIndex() {
// ...
}}
The GenreService
will need a getGenres
method. This should take the User
as its only argument, which we will use to find the starting point for the cypher query. From there, we will traverse the graph through to the genres that the user has permission to access.
So far we’ve been working with Neo4j Driver’s Node
data type. However, as this is public facing data, we should instead define a typescript interface. This will represent properties in the genre response.
// genre.service.ts
export interface Genre {
id: string;
name: string
}
In the GenreService
class, we can do some processing inside the service, returning the information outlined in the interface.
// genre.service.ts
async getGenresForUser(user: User): Promise<Genre[]> {
const res = await this.neo4jService.read(`
MATCH (u:User {id: $id})-[:PURCHASED]->(s:Subscription)-[:FOR_PACKAGE]->(p)-[:PROVIDES_ACCESS_TO]->(g)
WHERE s.expiresAt >= datetime()
RETURN g ORDER BY g.name ASC
`, {id: (user.properties as Record<string, any>).id}) return res.records.map(row => ({
...row.get('g').properties,
id: row.get('g').properties.id.toNumber(),
}))
}
The read
method on the Neo4jService
returns a Result
- which in turn exposes an array of records
. The return statement runs the map
function on that array, transforming the list of nodes into an array of plain objects. For each g
value returned from Neo4j, I've used the spread operator to mass-assign the properties to the node to the object, and then the final line converts the ID property from a Neo4j integer into a JavaScript integer.
return res.records.map(row => ({
...row.get('g').properties,
id: row.get('g').properties.id.toNumber(),
}))
Because we’re using a Neo4j integer type, we’ll need to call to toNumber
method to convert it back into a Neo4j integer. If the number is within a safe range (Number.MIN_SAFE_INTEGER
and Number.MAX_SAFE_INTEGER
) it will be returned as a number
, otherwise it will be returned as a string
.
All of the hard work is done within the service, so all that is left is to add the call to the new method into the GenreController
:
// genre.controller.ts
@UseGuards(JwtAuthGuard)
@Get('/')
async getIndex(@Request() request) {
return this.genreService.getGenresForUser(request.user)
}
We can add another group to the end-to-end tests, and another test to ensure that the functionality works correctly. The first two tests to check the the JwtAuthGuard
is working correctly can be copied and pasted from the GET /auth/user
, changing the URL inside .get(...)
.
To test the response, we can check that all 20 genre rows have been returned, and for each of those rows there should be keys for the id
and name
properties for the genre.
describe('GET /genres', () => {
it('should return unauthorised if no token is provided', () => {
return request(app.getHttpServer())
.get('/genres')
.expect(401)
}) it('should return unauthorised on incorrect token', () => {
return request(app.getHttpServer())
.get('/genres')
.set('Authorization', `Bearer incorrect`)
.expect(401)
}) it('should return a list of genres in exchange for a valid token', () => {
return request(app.getHttpServer())
.get('/genres')
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect(res => {
// User should have access to all 20 genres
expect(res.body.length).toEqual(20) // Each one of those genres should have id and name keys
res.body.forEach(row => {
expect(Object.keys(row)).toEqual(expect.arrayContaining(['id', 'name']))
})
})
})
})
Listing Videos in a Genre
The next feature that we should add to the API is an endpoint for the user to list movies within a genre that their subscription grants them access to. We’ll add a route handler in the GenreController
for GET
requests to /genre/:id
. As with the previous route, this route should be guarded by the JwtAuthGuard
, requiring a valid token to be sent with each request.
We can use decorators in the method while defining the method to inject the variables that we are interested in, and also use some pre-built Pipes to coerce the values into the correct format. In order to process this request we will need:
- The
:User
node as added to the Auth the JwtAuthGuard —@Request() request
- The ID parameter included in the URL — we can inject this by using the
@Param
decorator, supplying the value that has been prefixed with:
in the path as defined in the@Get
decorator. In this case, we need anumber
so we can use theParseIntPipe
decorator as the second argument to ensure that Nest validates the input and converts it to a number. - Optionally, the user may also provide some query parameters — for example if they click the Previous or Next buttons in the UI. These can all be extracted from
request.query
object using the@Query
decorator. We can also use theDefaultValuePipe
to supply a default value if none has been supplied. - The user may want to order the results, so we can expose an
orderBy
property - by default this should be the movie title -@Query('orderBy', new DefaultValuePipe('title'))
- Rather than the UI deciding the skip parameter to pass through to the Cypher, we’ll allow the UI to send a page parameter which it can easily increment/decrement. If no value has been supplied this should default to the first page. We also want this to be parsed into a number —
@Query('page', new DefaultValuePipe(1), ParseIntPipe)
- The user may want to change the limit to view more results in the UI:
@Query('limit', new DefaultValuePipe(10), ParseIntPipe)
Piecing these values together, we’ll get a route handler that looks something like this:
// genre.controller.ts
@UseGuards(JwtAuthGuard)
@Get('/:id')
async getGenre(
// Request object to get the User
@Request() request,
// Extract the ID
@Param('id', ParseIntPipe) id: number, // Extract the ID
// Which property to order by
@Query('orderBy', new DefaultValuePipe('title')) orderBy: string,
// Page number defaulting to the first page
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
// Total number of results to return, default 10
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number
) {
// ...
}
To process this request, we’ll pass on the responsibility to the GenreService
by creating agetMoviesForGenre
method. Like the getGenresForUser
method, we'll take the :User
node as the first parameter and add the rest of the parameters.
// genre.service.ts
async getMoviesForGenre(user: User, genreId: number, orderBy: string = 'title', limit: number = 10, page: number = 1) {
// ...
}
In this method, we’ll traverse the graph from the :User
, through their :Subscriptions
, to a :Package
which provides access to the requested :Genre. The subscriptions should be filtered to only include nodes that expire on or after the current date and time.
MATCH (u:User {id: $userId})-[:PURCHASED]->(s)-[:FOR_PACKAGE]->(p)-[:PROVIDES_ACCESS_TO]->(g:Genre {id: $genreId})
WHERE s.expiresAt >= datetime()
If the user doesn’t have a subscription to this package then no rows will be returned. For now that is fine because the request is for available videos in the genre, but in future we may want to return a HTTP 403 Forbidden
response so that the UI can encourage the user to buy a new subscription.
We should also add in a check on the User’s age — if they are under 18 years they shouldn’t be able to access any movie with the :Adult
label applied to it.
u.dateOfBirth <= datetime() - duration('P18Y') OR NOT m:Adult
Then we can traverse the IN_GENRE
relationship to the movie, then apply the ordering and pagination. Piecing this all together in the service will look like this:
/// genre.service.ts
async getMoviesForGenre(user: User, genreId: number, orderBy: string = 'title', limit: number = 10, page: number = 1) {
const res = await this.neo4jService.read(`
MATCH (u:User {id: $userId})-[:PURCHASED]->(s)-[:FOR_PACKAGE]->(p)-[:PROVIDES_ACCESS_TO]->(g:Genre {id: $genreId})<-[:IN_GENRE]-(m)
WHERE s.expiresAt >= datetime() AND (u.dateOfBirth <= datetime() - duration('P18Y') OR NOT m:Adult)
RETURN m,
[ (m)-[:IN_GENRE]->(g) | g ] as genres,
[ (m)<-[:CAST_FOR]-(p) | p ][0..5] as cast
ORDER BY m.title ASC
SKIP $skip
LIMIT $limit
`, {
userId: (user.properties as Record<string, any>).id,
genreId: int(genreId),
skip: int( (page - 1) * limit),
limit: int(limit),
}) return res.records.map(row => ({
...row.get('m').properties,
id: row.get('m').properties.id.toNumber(),
genres: row.get('genres'),
cast: row.get('cast'),
}))
}
In the RETURN
portion of the statement I have added a couple of Pattern Comprehensions to retrieve some extra information about the movie.
The response will look something like this:
{ "popularity": 6.183889,
"original_language": "en",
"vote_count": 23,
"average_vote": 6.7,
"id": 31527,
"release_date": "1934-05-09",
"status": "Released",
"revenue": 0,
"overview": "Young German princess Sophia is married off to Russia's half-mad Grand Duke Peter in the hope of improving the royal blood line.",
"budget": 900000,
"title": "The Scarlet Empress",
"imdb_id": "tt0025746",
"original_title": "The Scarlet Empress",
"poster_path": "/xa4t3CU168cVaNYL2g2dRMtSMDH.jpg",
"runtime": 104,
"cast": [
{
"profile_path": "/dKLUrgJSkcq7iPGliG81xL8Fdrw.jpg",
"id": 133470,
"gender": 0,
"name": "John Lodge"
},
{
"profile_path": "/geGrMcqxcFtMKKJO36OFpVwZFFW.jpg",
"id": 8515,
"gender": 1,
"name": "Jane Darwell"
},
{
"name": "Erville Alderson",
"id": 589728,
"gender": 2,
"profile_path": "/yi3crBgbU5m4jfsH3H15YNNPrpg.jpg"
},
{
"profile_path": "/iBqiyZ7nXnnLfFgXtMfwwazBx8X.jpg",
"id": 1070625,
"gender": 0,
"name": "Olive Tell"
},
{
"name": "Ruthelma Stevens",
"id": 1082774,
"gender": 0,
"profile_path": "/nOKxAwLIxUNrU2DFxGeLaV2nyLd.jpg"
}
],
"genres": [
{
"id": 18,
"name": "Drama"
},
{
"id": 10749,
"name": "Romance"
},
{
"id": 36,
"name": "History"
}
],
}
Again, to test this endpoint we can create another group in the end-to-end tests. When provided with a valid token, the API should return 10 movies:
describe('GET /genres/:id', () => {
it('should return a list of genres in exchange for a valid token', () => {
return request(app.getHttpServer())
.get(`/genres/1`)
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect(res => {
expect(res.body.length).toEqual(10)
})
})
})
Then, if a limit is supplied (in this case 20), the API should return that number of results:
it('should return a paginated list of genres in exchange for a valid token', () => {
const limit = 20
return request(app.getHttpServer())
.get(`/genres/$1?limit=${limit}`)
.set('Authorization', `Bearer ${token}`)
.expect(200)
.expect(res => {
expect(res.body.length).toEqual(limit)
})
})
You can make the tests more sophisticated than this, but for now it demonstrates the functionality works as intended.
Recap
In this session we’ve covered how to run some basic authorisation using the structure of the graph. When a user registers, they will have access to everything as part of a 30 day trial. Once that trial expires, they will be required to purchase a subscription.
We already have the methods available to create the new subscription in the SubscriptionService
, all we need to do is create another route handler to process the request. The tests are also pretty basic at the moment, so it is worth spending some time testing for different scenarios, for example if a user tries to access a genre that their subscription doesn't provide access to.
The API now also return data in Neo4j specific formats — for example datetime
or duration
which currently take a lot of repetitive code to convert. We'll look at this in more detail in the next session.
If you have any questions, comments or if you would like to see a feature added to the API, feel free to open a Github Issue.
Join me Tuesdays at 12:00 UTC / 13:00BST / 14:00CEST / 15:30 IST on the Neo4j Twitch Channel for the next session.