Skip to content

Commit

Permalink
feat: type-safe encode function [3/6] (#74)
Browse files Browse the repository at this point in the history
* WIP: type-safe encode function

* WIP: encode function skeleton

* feat: start working on lib/encode-conversions.ts, on observation

* Add tag conversion for decode

Co-authored-by: tomasciccola <tomasciccola@users.noreply.github.com>

* feat: convertField and convertProject on encode-converstions.ts

I realize Project has a missing acording to the revition we
did called `defaultPresets`. I'll try to add it and see how it goes...

* feat: add (commented for now) convertPreset to encode-convertions

* fix up encode convert functions

* Add preset encode/decode

---------

Co-authored-by: Tomás Ciccola <tciccola@digital-democracy.com>
Co-authored-by: tomasciccola <tomasciccola@users.noreply.github.com>
  • Loading branch information
3 people committed Aug 1, 2023
1 parent 096a669 commit 11291ce
Show file tree
Hide file tree
Showing 15 changed files with 477 additions and 72 deletions.
2 changes: 1 addition & 1 deletion buf.gen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ plugins:
# - useOptionals=none
- stringEnums=true
- enumsAsLiterals=true
- outputIndex=true
# - outputIndex=true
- oneof=unions
14 changes: 14 additions & 0 deletions proto/project/v1.proto
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,18 @@ message Project_1 {
Common_1 common = 1;

string name = 5;
/*
// You can't use an enum as a key in a map in protobufs :(
enum DefaultPresetsKey {
point = 0;
vertex = 1;
line = 2;
area = 3;
relation = 4;
};
message DefaultPresetsValue {
repeated string defaultPresetsValue = 1;
};
map<DefaultPresetsKey,DefaultPresetsValue> defaultPresetsValue= 6;
*/
}
7 changes: 4 additions & 3 deletions schema/observation/v5.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"position": {
"description": "Position details",
"type": "object",
"required": ["mocked"],
"properties": {
"timestamp": {
"description": "Timestamp of when the current position was obtained",
Expand Down Expand Up @@ -54,13 +55,13 @@
},
"lat": {
"description": "latitude of the observation",
"type": ["number", "null"],
"type": "number",
"minimum": -90,
"maximum": 90
},
"lon": {
"description": "longitude of the observation",
"type": ["number", "null"],
"type": "number",
"minimum": -180,
"maximum": 180
},
Expand Down Expand Up @@ -187,6 +188,6 @@
"additionalProperties": false
}
},
"required": ["schemaName", "tags"],
"required": ["schemaName", "tags", "refs", "attachments"],
"additionalProperties": false
}
67 changes: 55 additions & 12 deletions schema/preset/v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,47 @@
"$id": "http://mapeo.world/schemas/preset/v2.json",
"title": "Preset",
"description": "Presets define how map entities are displayed to the user. They define the icon used on the map, and the fields / questions shown to the user when they create or edit the entity on the map. The `tags` property of a preset is used to match the preset with observations, nodes, ways and relations. If multiple presets match, the one that matches the most tags is used.",
"definitions": {
"tags": {
"type": "object",
"properties": {},
"additionalProperties": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
},
{
"type": "null"
},
{
"type": "array",
"items": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "number"
},
{
"type": "string"
},
{
"type": "null"
}
]
}
}
]
}
}
},
"type": "object",
"properties": {
"schemaName": {
Expand All @@ -17,7 +58,6 @@
"geometry": {
"description": "Valid geometry types for the feature - this preset will only match features of this geometry type `\"point\", \"vertex\", \"line\", \"area\", \"relation\"`",
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
Expand All @@ -26,23 +66,17 @@
},
"tags": {
"description": "The tags are used to match the preset to existing map entities. You can match based on multiple tags E.g. if you have existing points with the tags `nature:tree` and `species:oak` then you can add both these tags here in order to match only oak trees.",
"type": "object",
"properties": {},
"additionalProperties": true
"$ref": "#/definitions/tags"
},
"addTags": {
"description": "Tags that are added when changing to the preset (default is the same value as 'tags')",
"type": "object",
"properties": {},
"additionalProperties": true
"$ref": "#/definitions/tags"
},
"removeTags": {
"description": "Tags that are removed when changing to another preset (default is the same value as 'addTags' which in turn defaults to 'tags')",
"type": "object",
"properties": {},
"additionalProperties": true
"$ref": "#/definitions/tags"
},
"fields": {
"fieldIds": {
"description": "hex-encoded string. IDs of fields to displayed to the user when the preset is created or edited",
"type": "array",
"items": {
Expand All @@ -61,6 +95,15 @@
}
}
},
"required": ["name", "geometry", "tags", "schemaName"],
"required": [
"name",
"geometry",
"tags",
"addTags",
"removeTags",
"fieldIds",
"schemaName",
"terms"
],
"additionalProperties": false
}
7 changes: 7 additions & 0 deletions scripts/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { generateConfig } from './lib/generate-config.js'
import { readJSONSchema } from './lib/read-json-schema.js'
import { generateValidations } from './lib/generate-validations.js'
import { generateJSONSchemaTS } from './lib/generate-jsonschema-ts.js'
import { generateEncodeDecode } from './lib/generate-encode-decode.js'

const DIST_DIRNAME = path.join(PROJECT_ROOT, 'dist')
const TYPES_DIRNAME = path.join(PROJECT_ROOT, 'types')
Expand All @@ -32,6 +33,12 @@ fs.writeFileSync(
protoTypesFile
)

const encodeDecodeFile = generateEncodeDecode(config)
fs.writeFileSync(
path.join(PROJECT_ROOT, 'types/proto/index.ts'),
encodeDecodeFile
)

const configFile = generateConfig(config)
fs.writeFileSync(path.join(PROJECT_ROOT, 'config.ts'), configFile)

Expand Down
31 changes: 31 additions & 0 deletions scripts/lib/generate-encode-decode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// @ts-check
/**
* @param {ReturnType<import('./parse-config.js').parseConfig>} config
*/
export function generateEncodeDecode({ currentSchemaVersions, protoTypeDefs }) {
const typeImports = protoTypeDefs.map(
({ schemaName, schemaVersion, typeName }) => {
return `import { ${typeName} } from './${schemaName}/v${schemaVersion}'`
}
)

const currentProtoTypeDefs = protoTypeDefs.filter(
({ schemaName, schemaVersion }) => {
return currentSchemaVersions[schemaName] === schemaVersion
}
)

const encodeLines = ['export const Encode = {']
for (const { schemaName, typeName } of currentProtoTypeDefs) {
encodeLines.push(` ${schemaName}: ${typeName}.encode,`)
}
encodeLines.push('}')

const decodeLines = ['export const Decode = {']
for (const { typeName } of protoTypeDefs) {
decodeLines.push(` ${typeName}: ${typeName}.decode,`)
}
decodeLines.push('}')

return [...typeImports, '', ...encodeLines, '', ...decodeLines, ''].join('\n')
}
21 changes: 9 additions & 12 deletions scripts/lib/generate-proto-types.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
// @ts-check

// types/proto/index.d.ts and types/proto/index.js
import { capitalize } from './utils.js'
import { getTypeName } from './utils.js'

/**
* @param {ReturnType<import('./parse-config.js').parseConfig>} config
Expand All @@ -22,25 +20,27 @@ export function generateProtoTypes({ currentSchemaVersions, protoTypeDefs }) {
.join('\n | ')

const protoTypesWithSchemaInfo =
'/** Union of all Proto Types (including non-current versions) with schemaName and schemaVersion */' +
'/** Union of all Proto Types (including non-current versions) with schemaName and schemaVersion */\n' +
'export type ProtoTypesWithSchemaInfo =\n | ' +
protoTypeDefs
.map(({ schemaName, schemaVersion, typeName }) => {
return `${typeName} & { schemaName: '${schemaName}', schemaVersion: ${schemaVersion} }`
return `(${typeName} & { schemaName: '${schemaName}', schemaVersion: ${schemaVersion} })`
})
.join('\n | ')

const currentProtoTypes =
'export type CurrentProtoTypes =\n | ' +
'/** Union of current proto types */\n' +
'export type CurrentProtoTypes = {\n' +
protoTypeDefs
.filter(({ schemaName, schemaVersion }) => {
return currentSchemaVersions[schemaName] === schemaVersion
})
.map(({ schemaName, schemaVersion }) => {
const typeName = getTypeName(schemaName, schemaVersion)
return `${typeName}`
return ` ${schemaName}: ${typeName},`
})
.join('\n | ')
.join('\n') +
'}\n'

const protoTypesExports = protoTypeDefs
.map(({ typeName }) => {
Expand All @@ -49,6 +49,7 @@ export function generateProtoTypes({ currentSchemaVersions, protoTypeDefs }) {
.join('\n')

const protoTypeNames =
'/** Union of all valid names of proto types (`${capitalizedSchemaName}_${schemaVersion}`) */\n' +
'export type ProtoTypeNames =\n | ' +
protoTypeDefs.map(({ typeName }) => `'${typeName}'`).join('\n | ')

Expand All @@ -67,7 +68,3 @@ export function generateProtoTypes({ currentSchemaVersions, protoTypeDefs }) {
'\n'
)
}

function getTypeName(schemaName, schemaVersion) {
return capitalize(schemaName) + '_' + schemaVersion
}
4 changes: 4 additions & 0 deletions scripts/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}

export function getTypeName(schemaName, schemaVersion) {
return capitalize(schemaName) + '_' + schemaVersion
}

export const PROJECT_ROOT = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'../..'
Expand Down
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Length in bytes of data type ID encoding */
export const DATA_TYPE_ID_BYTES = 6
/** Length in bytes of schema version encoding */
export const SCHEMA_VERSION_BYTES = 2
44 changes: 16 additions & 28 deletions src/decode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ProtoTypeNames, ProtoTypes } from '../types/proto/types'
import { ProtoTypes } from '../types/proto/types'
import {
type JsonSchemaTypes,
type ProtoTypesWithSchemaInfo,
Expand All @@ -8,18 +8,18 @@ import {
type ValidSchemaDef,
} from './types'

import * as ProtobufEncodeDecode from '../types/proto/index.mapeo'
import { Decode } from '../types/proto/index'
import { dataTypeIds, knownSchemaVersions } from '../config'
import {
convertProject,
convertField,
convertObservation,
convertPreset,
} from './lib/decode-conversions'
// @ts-ignore
import * as cenc from 'compact-encoding'

const dataTypeIdSize = 6
const schemaVersionSize = 2
import { DATA_TYPE_ID_BYTES, SCHEMA_VERSION_BYTES } from './constants'
import { getProtoTypeName } from './lib/utils'

/** Map of dataTypeIds to schema names for quick lookups */
const dataTypeIdToSchemaName: Record<string, SchemaName> = {}
Expand All @@ -39,14 +39,14 @@ export function decode(buf: Buffer, versionObj: VersionObj): JsonSchemaTypes {
const schemaDef = decodeBlockPrefix(buf)

const encodedMsg = buf.subarray(
dataTypeIdSize + schemaVersionSize,
DATA_TYPE_ID_BYTES + SCHEMA_VERSION_BYTES,
buf.length
)

const messageWithSchemaInfo =
ProtobufEncodeDecode[getProtoTypeName(schemaDef)].decode(encodedMsg)
const messageWithoutSchemaInfo =
Decode[getProtoTypeName(schemaDef)](encodedMsg)

const message = mutatingSetSchemaDef(messageWithSchemaInfo, schemaDef)
const message = mutatingSetSchemaDef(messageWithoutSchemaInfo, schemaDef)

switch (message.schemaName) {
case 'project':
Expand All @@ -55,6 +55,8 @@ export function decode(buf: Buffer, versionObj: VersionObj): JsonSchemaTypes {
return convertObservation(message, versionObj)
case 'field':
return convertField(message, versionObj)
case 'preset':
return convertPreset(message, versionObj)
default:
const _exhaustiveCheck: never = message
return message
Expand All @@ -67,21 +69,21 @@ export function decode(buf: Buffer, versionObj: VersionObj): JsonSchemaTypes {
* Will throw if dataTypeId and schema version is unknown
*/
export function decodeBlockPrefix(buf: Buffer): ValidSchemaDef {
if (buf.length < dataTypeIdSize + schemaVersionSize) {
if (buf.length < DATA_TYPE_ID_BYTES + SCHEMA_VERSION_BYTES) {
throw new Error('Invalid block prefix - unexpected prefix length')
}
const state = cenc.state()
state.buffer = buf
state.start = 0
state.end = dataTypeIdSize
const dataTypeId = cenc.hex.fixed(dataTypeIdSize).decode(state)
state.end = DATA_TYPE_ID_BYTES
const dataTypeId = cenc.hex.fixed(DATA_TYPE_ID_BYTES).decode(state)

if (typeof dataTypeId !== 'string') {
throw new Error('Invalid block prefix, could not decode dataTypeId')
}

state.start = dataTypeIdSize
state.end = dataTypeIdSize + schemaVersionSize
state.start = DATA_TYPE_ID_BYTES
state.end = DATA_TYPE_ID_BYTES + SCHEMA_VERSION_BYTES
const schemaVersion = cenc.uint16.decode(state)

if (typeof schemaVersion !== 'number') {
Expand Down Expand Up @@ -131,20 +133,6 @@ function assertKnownSchemaDef(schemaDef: {
}
}

/**
* Get the name of the type, e.g. `Observation_5` for schemaName `observation`
* and schemaVersion `1`
*/
function getProtoTypeName(schemaDef: ValidSchemaDef): ProtoTypeNames {
return (capitalize(schemaDef.schemaName) +
'_' +
schemaDef.schemaVersion) as ProtoTypeNames
}

function capitalize<T extends string>(str: T): Capitalize<T> {
return (str.charAt(0).toUpperCase() + str.slice(1)) as any
}

// function mutatingOmit<T, K extends keyof any>(obj: T, key: K): OmitUnion<T, K> {
// delete (obj as any)[key]
// return obj as any
Expand Down
Loading

0 comments on commit 11291ce

Please sign in to comment.