ZettelKasten part 5
An API worth making requests to
This is a followup to a previous article, ZettelKasten Part 4
In the previous article I went down a huge rabbithole but at least I updated my Grakn version. In this part we'll be updating previous API methods to work with this version of Grakn and then getting athe API more complete.
Updating the API
So now that I've got my grakn instance running a higher instance type, time to downgrade it. Because the node client isn't currently at the bleeding edge. But we'll at least get as close to bleeding as possible without requiring a tournequette.
docker run --name grakn -d -v $(pwd)/db:/grakn-core-all-linux/server/db/ -p 1729:1729 graknlabs/grakn:2.0.0-alpha-3
Then in my directory for the zettelkasten server:
yarn upgrade grakn-client@2.0.0-alpha-7
This is to save you a bunch of pain if you're actually following along and don't want arbitrary functions to not work randomly due to a mismatch in the RPC protocol between the server and the client.
Updating the Schema
Now we have to rewrite our schema to make a few particular changes, most of which are visible in this set of code:
zettel sub entity,
owns content,
owns date,
owns name,
plays zettel-citation:cites,
plays zettel-link:linked,
plays zettel-link:linker,
plays zettel-tagged:tagged,
owns uuid @key;
owns
is the newhas
.has
is now forThing
s (like instances) andowns
is forType
s (like classes).Relation
s now have scopedrole
s. Which means that we can stop giving terrible names to all our relations but instead we have to prefix them with the relation they're scoped to. I knew this was coming so I named them smartly before now.@key
replaceskey
, so now we properly sayowns uuid
instead ofkey uuid
and@key
is a little decorator at the end there.
The other change can be observed here:
uri sub attribute, abstract,
value string;
You won't find that one in their documentation. You can now only subtype attributes which are abstract. This explicitly contradicts Grakn's documentation and it's probably a bug.
Update the migration script
We had our little script that did the migration, now's an excellent time to visit it and see how the differences in the new version of Grakn affect it.
First the imports:
import Path from 'path';
import {Grakn, GraknClient} from 'grakn-client/dependencies_internal';
import {readFileSync} from 'fs';
Yeah, it's a little wonky, reaching right into that dependencies_internal
file. Hopefully a better way comes along.
const database = 'zettelkasten';
const graql = readFileSync(Path.join(__dirname,
'./migrations/zettelkasten1.graql'),
'utf8')
const client = new GraknClient('localhost:1729');
const databaseExists: boolean = await client.databases().contains(database);
if (!databaseExists) {
await client.databases().create(database);
}
const session = await client.session(database, Grakn.SessionType.SCHEMA);
const writeTransaction = await session.transaction(
Grakn.TransactionType.WRITE
);
await writeTransaction.query().define(graql);
await writeTransaction.commit();
await writeTransaction.close()
await session.close();
client.close();
I was going to just show the lines that changed, but that's basically all of them.
The port has changed to 1729
.
There's a DatabaseManager
which I get with client.databases()
in
order to create the database if I don't find it.
The Session
now requires a Grakn.SessionType
.
The Transaction
now requires a Grakn.TransactionType
instead of
being session.transaction().write()
.
A QueryManager
is now summoned with transaction.query()
and is used to
issue particular types of queries, in our case define()
with the text of our
graql query.
I now await
for the Session
to close (I maybe should have been already). I
also close the Transaction
but I'm not sure I need to after the commit.
You no longer need to await
for the client to close - it's synchronous.
Update the API itself
The API needed extensive updates but I also made some parts nicer.
First, the imports:
import Koa, {Context as KoaContext, Middleware, Next} from 'koa';
import {GraknClient} from 'grakn-client/dependencies_internal';
import {Grakn} from 'grakn-client/Grakn';
I've renamed Context because I'm going to have my own Context to make my typescript stuff easier to work with.
const graknClient = new GraknClient('localhost:1729');
const database = 'zettelkasten';
interface Context extends KoaContext {
graknSession?: Grakn.Session,
graknTransaction?: Grakn.Transaction
}
Our session middleware is mostly unchanged:
const graknSessionMiddleware: Middleware = async (ctx: Context,
next: Next) => {
ctx.graknSession = await graknClient.session(database,
Grakn.SessionType.DATA);
await next();
await ctx.graknSession.close();
delete ctx.graknSession;
};
And our Write and Read middleware are also mostly unchanged. Mostly just
changes to use the TransactionType
now.
const writeTransactionMiddleware: Middleware = async (ctx: Context,
next: Next) => {
ctx.graknTransaction = await ctx.graknSession.transaction(
Grakn.TransactionType.WRITE);
await next();
await ctx.graknTransaction.commit();
delete ctx.graknTransaction;
}
const readTransactionMiddleware: Middleware = async (ctx: Context,
next: Next) => {
ctx.graknTransaction = await ctx.graknSession.transaction(
Grakn.TransactionType.READ);
await next();
await ctx.graknTransaction.close();
}
Let's take a look at the new uses of the Concepts API. In particular, the
POST
for making a new zettel
const zettelRequest = ctx.request.body;
const transaction = ctx.graknTransaction;
const concepts = transaction.concepts();
const ZettelType = await concepts.getEntityType('zettel');
const ContentType = await concepts.getAttributeType('content');
const DateType = await concepts.getAttributeType('date');
const NameType = await concepts.getAttributeType('name');
const UUIDType = await concepts.getAttributeType('uuid');
const content = await ContentType.asRemote(transaction).asString()
.put(zettelRequest.content);
const date = await DateType.asRemote(transaction).asDateTime()
.put(new Date());
const name = await NameType.asRemote(transaction).asString()
.put(zettelRequest.name);
const uuid = await UUIDType.asRemote(transaction).asString()
.put(UUID());
const zettel = await ZettelType.asRemote(transaction).create();
const remoteZettel = zettel.asRemote(transaction);
Can you see why I want to write a more fluent API? So many await
s. But
there's more to come. For now, let's look at how this works now.
We've got this Manager
pattern again with ConceptManager
which is what we
get from transaction.concepts()
. We then get Types
for zettel
,
content
, date
, name
, and uuid
. However, now we specify the exact
Type
because Grakn now enforces this behavior. We then convert our local
concepts into remote concepts with asRemote(transaction)
- note that this
just means we have access to up-to-date information on the concept and some
more interesting features like creating some. Each of these attributes then
has as
with some type called on it, like asString()
- this way we get a
StringAttributeType
for example - just more type enforcement. With this we
call put(value)
with the values we want. Note this doesn't actually take
effect until we commit()
the transaction, which occurs at the end of the
write middleware.
The put
calls give us a Thing
, particularly an Attribute
corresponding
to the AttributeType
we were interacting with. Again, the distinction to be
made: A Type
is schema information, the shape of data, a class in essence.
The Thing
is data, an instance essentially.
Once we've got that, we make our Zettel using our ZettelType
which is an
EntityType
. EntityType
s use create()
instead of put
because Entity
s
don't contain values, they just has
Attribute
s they
have, which conform to the the AttributeType
s described as owns
on the
EntityType
.
And that has
ing is what we do next.
await Promise.all([
remoteZettel.setHas(content), remoteZettel.setHas(date),
remoteZettel.setHas(name), remoteZettel.setHas(uuid)
]);
And then one of the reasons I want to make a nicer serializer:
const contentObject = {type: 'attribute', label: 'content',
id: content.getIID(), value: content.getValue()};
const dateObject = {type: 'attribute', label: 'date', id: date.getIID(),
value: date.getValue()};
const uuidObject = {type: 'attribute', label: 'uuid', id: uuid.getIID(),
value: uuid.getValue()};
const nameObject = {type: 'attribute', label: 'name', id: name.getIID(),
value: name.getValue()};
const zettelObject = {type: 'entity', label: 'zettel',
id: zettel.getIID(), attributes: {
uuid: uuidObject, name: nameObject,
date: dateObject, content: contentObject}
};
Now for the thing that reads all the zettel.
const transaction = ctx.graknTransaction;
const ZettelType = await transaction.concepts().getEntityType('zettel');
const zettelInstancesStream = ZettelType.asRemote(transaction)
.getInstances();
So far so good.
//this won't be necessary once Grakn core alpha-9 has a node client
//implementation
//TODO get rid of this mess once on core alpha-9
const zettel = await Promise.all(await ZettelType
.asRemote(ctx.graknTransaction).getInstances()
.map(eachZettel => eachZettel.asRemote(transaction).getHas()
.collect().then(attributes => Promise.all(attributes.map(
attribute => attribute.asRemote(transaction).getType().then(
attributeType => ([
attributeType.getLabel(),
attribute.getValue()
])
)))
.then(entries => entries.reduce((object, entry) => {
object[entry[0].toString()] = entry[1];
return object;
}, {type: 'zettel'}))
)).collect());
WTF is going on here?
Well, a lot. And this right here is why I want to write a more fluent Grakn Node.js Client API. To summarize: I want to get a nice set of zettels with all their attributes. To do that, I need to
- Get all the instances of zettels
- For every Zettel get all its attributes
- For every attribute get its label and value
- Make an object out of the attributes
- Return an array of all that stuff.
And you can see also I serialized it entirely differently here. I'm going to write a standard serialier for entities that'll take care of this by standardizing how we do this.
In the future, as noted by the big comment, we won't need to do this because we'll get the type with every attribute we get, so we don't need to turn around and ask for it.
Improving the API
Serialization and Deserialization
Let's write a serialization system. I want it to apply generally to any entity in Grakn. We're not going to worry about relations for now, it just needs to make a nice object out of an entity with the attributes.
I imagine that a Zettel should look like this:
{
"uuid": uuid,
"name": name,
"content": content,
"date": date
}
Something like that. I'm not going to bother putting in the fact that its a
zettel - that's apparent from the API resource location. So I'll avoid
metadata. Basically just stick the attributes on there, {label: value}
.
However, an interesting concern arises. Our schema says content
is not just
a StringAttribute
- it also has metadata about the content: mime-type. So
we'll need the ability for attributes to have a value and their own
attributes. So we'll do it this way:
{
"uuid": {
"value": uuid
},
"name": {
"value": name
},
"uuid": {
"value": uuid
"mime-type": {
"value": mimeType,
"mime-type-type": {
"value": mimeTypeType
},
"mime-type-subtype": {
"value": mimeTypSubtype
}
}
},
"name": {
"value": name
}
}
Hmmm, ugly. Not great. We maybe should go for something more universal.
{
type: {label},
has: [
{
type: {label},
value
}
],
relations: [
{
type: {label},
playersByRoleType: {
[roleType] => Thing
}
}
]
}
This is at least a more universal way of serializing we can use all sorts of places. Important notes:
- We'll need to detect when recursion occurs and avoid it.
- We need to determine how far to go when reading records to avoid just traveling down some long path.
- We can probably use this to also deserialize records.
This looks awfully close to an interface
in Typescript, so let's make
interface
s for our main friends. Entity
, Attribute
, and Relation
, and
Thing
- probably this is the start to a more fluid API. We'll just build it
as we need it and split it out when it makes sense.
So we have a SerializedType
export interface SerializedType {
label: string
}
And SerializedThing
s
import {SerializedType} from './SerializedTypes';
export interface SerializedThing {
iid?: string,
type: SerializedType, // for now we don't need to distinguish Thing from any
// other Type
has?: SerializedAttribute[],
relations?: SerializedRelation[]
}
export interface SerializedAttribute extends SerializedThing {
value: string | Date | number | boolean
}
export interface SerializedRelation extends SerializedThing {
playersByRoleType: {
[roleType: string]: SerializedThing
}
}
Now we just need something that will serialize Things.
import {SerializedAttribute, SerializedThing} from './SerializedThings';
import {SerializedType} from './SerializedTypes';
import {Thing, Grakn, Type, Attribute, AttributeType} from 'grakn-client/dependencies_internal';
export const serializeThing = async (thing: Thing, transaction: Grakn.Transaction
): Promise<SerializedThing> => {
const remoteThing = thing.asRemote(transaction);
//this won't be necessary once Grakn core alpha-9 has a node client
//implementation
const type = await remoteThing.getType().then(serializeType);
const has: SerializedAttribute[] = await Promise.all(
await remoteThing.getHas().map(
attribute => serializeAttribute(attribute, transaction)
).collect()
);
let obj: SerializedThing = {type};
if (has.length > 0) {
obj = {...obj, has};
}
return obj;
}
export const serializeAttribute = async (attribute: Attribute<AttributeType.ValueClass>,
transaction: Grakn.Transaction): Promise<SerializedAttribute> => {
const thing = await serializeThing(attribute, transaction);
const value = attribute.getValue();
return {...thing, value};
}
export const serializeType = (type: Type): SerializedType => {
return {label: type.getLabel()};
}
There we go, and now to update our GET /
:
.get('/', async (ctx: Context) => {
const transaction = ctx.graknTransaction;
const ZettelType = await transaction.concepts().getEntityType('zettel');
const zettel = await ZettelType.asRemote(ctx.graknTransaction)
.getInstances().map(
eachZettel => serializeThing(eachZettel, transaction)).collect();
ctx.body = zettel;
})
And our POST /
:
await Promise.all([
remoteZettel.setHas(content), remoteZettel.setHas(date),
remoteZettel.setHas(name), remoteZettel.setHas(uuid)
]);
const serializedZettel = await serializeThing(remoteZettel, transaction);
ctx.body = serializedZettel;
Alright, we're on a roll.
I'm going to write another post tonight or tomorrow as I add:
- Updates
- Delete
- Show individual Zettel
The adventure continues in ZettelKasten Part 6