Diligent Dilettante

ZettelKasten part 6

Going full API

It's officially "tonight or tomorrow"

This is a followup to a previous article, ZettelKasten Part 5

Upgrading Grakn

Well, big news since "yesterday" when I wrote the previous article. Grakn 2.0 has been fully released.

So the first thing I've got to do is get everything working with the newest version.

docker pull graknlabs/grakn:2.0.2
docker run --name grakn -d -v $(pwd)/db:/grakn-core-all-linux/server/db/ -p 1729:1729 graknlabs/grakn:2.0.2

And then we run

yarn tsc

And everything breaks. Here's what I needed to change:

No need to grab dependencies_internal anymore.

const graknClient = Grakn.coreClient('localhost:1729');

Types have their own imports

import {GraknSession,
SessionType} from 'grakn-client/api/GraknSession';
import {GraknTransaction,
TransactionType} from 'grakn-client/api/GraknTransaction';

interface Context extends KoaContext {
graknSession?: GraknSession,
graknTransaction?: GraknTransaction
}

Everything around Concepts and Attributes and stuff is a bit better

import {AttributeType}    from 'grakn-client/api/concept/type/AttributeType';
import {Attribute} from 'grakn-client/api/concept/thing/Attribute';
import {GraknTransaction} from 'grakn-client/api/GraknTransaction';
import {Thing} from 'grakn-client/api/concept/thing/Thing';
import {Type} from 'grakn-client/api/concept/type/Type';

import {SerializedAttribute, SerializedThing} from './SerializedThings';
import {SerializedType} from './SerializedTypes';

export const serializeThing = async (thing: Thing,
transaction: GraknTransaction
): Promise<SerializedThing> => {
const remoteThing = thing.asRemote(transaction);
//this won't be necessary once Grakn core alpha-9 has a node client
//implementation
const type = serializeType(remoteThing.getType());
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: GraknTransaction
): 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().name()};
}

There's also some confusion in their types: their documentation references you should await transaction.commit(), but the return type of commit is void, not Promise<void>. As far as I can tell, await indeed does nothing.

But that doesn't seem to cause any problems. I ran my requests through Postman again, and it's fine.

Show time

Let's write a show for a single zettel.

Unfortunately we can't use the concept API to make a query (would be wonderful if we could) - we'll have to drop a string query right in there. We then turn it into a Thing and then use our serializer.

Here it is:

  .get('/:id', async (ctx: Context) => {
const transaction = ctx.graknTransaction;
const responseMapPromise: Promise<ConceptMap> = transaction.query().match(
`match $zettel isa zettel, has uuid "${ctx.params.id}";
get $zettel;
`

).first();
const responseMap = await responseMapPromise;
if(!responseMap)
throw(new NotFoundException(`Zettel not found for id ${ctx.params.id}`));
const zettelConcept: Concept = responseMap.get('zettel')
if(!zettelConcept)
throw(new NotFoundException(`Zettel not found for id ${ctx.params.id}`));
if(!zettelConcept.isThing())
throw(new NotFoundException(`Zettel not found for id ${ctx.params.id}`));
ctx.body = await serializeThing(zettelConcept as Thing,
transaction);
})

And we've got some exception handling now:

const handleErrors = (ctx: Context, error: Error) => {
if (error instanceof NotFoundException){
ctx.body = {error: error.message}
ctx.status = error.statusCode;
}
}

Just handling 404s for now, we'll expand it later. We similarly expand our write and read middleware

  try{
await next();
}catch(error){
handleErrors(ctx, error);
}finally{
await ctx.graknTransaction.close();
delete ctx.graknTransaction
}

Breaking some stuff out

I'm going to move a bunch of this stuff that interacts with Grakn to a more universal thing. DRY and whatnot.

Basically I moved all the session middleware stuff into src/middleware/grakn/sessionMiddleware.ts. Not too complicated, won't show my work.

Adding an Update action

So, now we'll need to be able to update. Our implementation will basically be a hybrid of our GET and our CREATE actions, which means we're likely going to find some overlap. When we do we'll consider optimizing it into a single pattern. I have a feeling I'm going to want a more universal "Update some Thing" pattern, a description of the model that automates this kind of stuff. But keeping it particular to Zettel is fine for now. YAGNI and all that, no need to start abstracting until we need it - my point of this series is to make this fast. It's been fast, just slow to blog about because I spend so little time on it.

Redoing show

Seriously why do I keep flip-flopping?

Well I prefer the concept API and I found there's a way to do that instead of writing a query string.

    const transaction = ctx.graknTransaction;
const concepts = transaction.concepts();
const UUIDType: AttributeType.String = (await concepts.getAttributeType('uuid')).asString();
const uuid = await UUIDType.asRemote(transaction).get(ctx.params.id);
const ZettelType = await concepts.getEntityType(EntityTypes.Zettel);
const zettel: Thing = await uuid.asRemote(transaction).getOwners(ZettelType).first();
if(!zettel)
throw(new NotFoundException(`Zettel not found for id ${ctx.params.id}`));
ctx.body = await serializeThing(zettel, transaction);

Much nicer.

Adding an update action actually this time

I start by moving the new get stuff into its own middleware:

export const getZettelMiddleware: Middleware = async (ctx: Context, 
next: Next) => {
const transaction = ctx.graknTransaction;
const concepts = transaction.concepts();
const UUIDType: AttributeType.String = (
await concepts.getAttributeType('uuid')).asString();
const ZettelType = await transaction.concepts().getEntityType(
EntityTypes.Zettel
);
const uuid = await UUIDType.asRemote(transaction).get(ctx.params.id);
const zettel: Thing = await uuid.asRemote(transaction)
.getOwners(ZettelType).first();
if(!zettel)
throw(new NotFoundException(`Zettel not found for id ${ctx.params.id}`));

ctx.zettel = zettel;
await next();
}

And I also modified the schema and dropped the database. zettel and zettel-reference look like this now:

zettel sub entity,
owns content,
owns created-at,
owns modified-at,
owns name,
plays zettel-citation:cites,
plays zettel-link:linked,
plays zettel-link:linker,
plays zettel-tagged:tagged,
owns uuid @key;

zettel-reference sub entity,
owns canonical-uri,
owns created-at,
owns modified-at,
plays zettel-citation:cited,
plays zettel-link:linked,
owns archive-uri;

There is created-at and modified-at

I put the middleware into the show action:

  .get('/:id', getZettelMiddleware, async (ctx: Context) => {
ctx.body = await serializeThing(ctx.zettel, ctx.graknTransaction);
})

And I made the post /:id action for updates:

  .post('/:id', getZettelMiddleware, async (ctx: Context) => {
const zettelRequest = ctx.request.body;
const transaction = ctx.graknTransaction;
const concepts = transaction.concepts();
const zettel = ctx.zettel.asRemote(transaction);
let modified = false;
// for each attribute present, out with the old, in with the new
if (zettelRequest.content) {
const ContentType = (await concepts.getAttributeType('content'))
.asString();
const newContent = await ContentType.asRemote(transaction)
.put(zettelRequest.content);
const oldContent = await zettel.getHas(ContentType).first();
await zettel.unsetHas(oldContent);
await zettel.setHas(newContent);
modified = true;
}
if (zettelRequest.name) {
const NameType = (await concepts.getAttributeType('name'))
.asString();
const newName = await NameType.asRemote(transaction)
.put(zettelRequest.name)
const oldName = await zettel.getHas(NameType).first();
await zettel.unsetHas(oldName);
await zettel.setHas(newName);
modified = true;
}
//set a new modified at time
if (modified) {
const ModifiedAtType = (await concepts.getAttributeType('modified-at'))
.asDateTime();
const newModifiedAt = await ModifiedAtType.asRemote(transaction)
.put(new Date());
await zettel.setHas(newModifiedAt);
}
const serializedZettel = await serializeThing(zettel, transaction);
ctx.body = serializedZettel;
});

Now we've got a everything we need.

But I'm going to rewrite the serializer for zettels. It doesn't match what we're posting when we create them. So, while I have a nice one that works universally, I don't like it.

export const serializeZettel = async (localZettel: Thing, 
transaction: GraknTransaction) => {
const zettel = localZettel.asRemote(transaction);
const concepts = transaction.concepts();
const ContentType = (await concepts.getAttributeType('content'))
.asString();
const NameType = (await concepts.getAttributeType('name'))
.asString();
const CreatedAtType = (await concepts.getAttributeType('created-at'))
.asDateTime();
const ModifiedAtType = (await concepts.getAttributeType('modified-at'))
.asDateTime();
const UUIDType = (await concepts.getAttributeType('uuid'))
.asString();
const name = (await zettel.getHas(NameType).first()).getValue();
const uuid = (await zettel.getHas(UUIDType).first()).getValue();
const content = (await zettel.getHas(ContentType).first()).getValue();
const createdAt = (await zettel.getHas(CreatedAtType).first()).getValue().toISOString();
const modifiedAt = (await zettel.getHas(ModifiedAtType).collect()).sort(
(attribute1, attribute2) => attribute1.getValue().valueOf() - attribute2.getValue().valueOf()
).reverse()[0]?.getValue().toISOString();

return {
name,
uuid,
content,
createdAt,
modifiedAt
}
}

Note the [0]? in there. If we don't have any values for modifiedAt, we need to make sure we catch that.

We could be more cautious, but like I said, we're doing this rough.

And then I just replace serializeThing with serializeZettel whereever I encounter it.

Beautiful. Next up:

  1. Source maps.
  2. Reloading the APIs when I make code changes
  3. A basic react app
  4. Making that react app hot-reloading.
  5. Making that react app able to write and read and list Zettel.
  6. Making that react app render server-side.
  7. Making that react app a react-native app as well.

The adventure continues in ZettelKasten Part 7