Relationships

Most of the code in any project is related.

  • Data models determine the structure of your queries
  • The queries you write affect the structure of your API
  • The API determines what your HTTP Requests look like
  • The API influences the forms/state management of each client ...plus many other relationships specific to the type of application you are writing.

We think of these relationships as implicit dependencies, and in any reasonably medium sized codebase there are hundred of them to manage. Whenever you update code in one place and find yourself tracking down all the other code that breaks -- that's you manually managing your implicit dependencies just like people used to manually download/compile the modules they needed before there were good package managers.

Implicit dependencies are usually manifested in the glue-code and boilerplate. Using Optic relationships can help you write and maintain this code throughout your projects, even when the implicit dependencies cross languages (Python backend + JS frontend).

Using Relationships in Optic

There are two ways to use relationships in Optic.

  1. Generating Related Code & Move Faster - Optic can automatically generate code that interfaced with another part of your project. For instance, you can direct Optic to generate a React form on your client that sends data to one of the endpoints in your API. Since Optic knows the method, parameters, and authentication the server expects it can write the correct code for you. Direct access to these generators can sidestep the need for keeping documentation updated.

  2. Keeping code in Sync - Every project evolves, and that means you need to manually update all your implicit dependencies. Optic can tell you which code will be affected by any change, and generate a pull request to make the required updates. Doing this manually isn't rocket science but it's slow, low-value work that's prone to mistakes.

Creating Relationships

Relationships can be defined between any 2 generators or abstractions. For example, if you wanted to keep an API/client in sync, you would need a Relationship from optic:rest/endpoint -> optic:rest/http-request.

There can be as many relationships between the same 2 generators and abstractions as you find useful. Each one is made unique by a property called yields that's similar to a name. The above Relationship between optic:rest/endpoint and optic:rest/http-request would yield a Route from Endpoint. We could also have a set of relationships between a database model and an API Endpoint that yield a Create Endpoint, Read Endpoint, Update Endpoint, or Delete Endpoint.

The most important part of a Relationship is the transform function that takes input of type A and returns type B. The transform function operates on the level of the abstractions used by Optic Generators, they never deal with any raw source code. The input argument contains this abstract model representing whatever is being transformed. You then map that object into one that conforms to the output abstraction and return that value.

The most basic Relationship we could make would look like this:

Relationship(
'Foo from Bar', //yields (name)
'foo-from-bar', //id
'foo',          //input type
'bar',          //output type
(input) => {
	const bar = {
		value: input.foo
	}
	return bar
})

Here's a real world example that maps an API Endpoint to an HTTP Request:

import {Relationship} from "optic-skills-sdk";

export const requestFromRoute = Relationship(
'Request from Route', //yields (name)
'request-from-route', //id
'optic:rest/route',   //input type
'request',            //output type
(input) => {
	const request = {
		method: input.method,
		options: {
			uri: input.url
			query: input.parameters.filter(i=> i.in === 'query'),
			body: input.parameters.filter(i=> i.in === 'body')
		}
	}

	return request
})

At the code level, this Relationship, defined once, would keep any code like this in sync:

//the input endpoint (from backend)
app.get('/hello', (req, res) => {
    res.send(200, 'Hello '+req.query.name)
}

//the output request (on frontend)
request.get({ uri: '/hello', qs: { name: name }}, function (err, response, body) {
	//any handler...
})

Gathering Additional Information

Some Relationships require additional information Optic can't infer from your existing code. For instance, when generating a API endpoint, you might want to offer a flag for authenticated that affects how that route is rendered.

The SDK supports requesting additional information from the user with a class called Ask.

const ask = Ask()
ask.forPrimitive('test', 'used for testing', 'string')

const exampleRelationship = Relationship('transformed', 'example-transform', 'hello:abc', 'hello:def', (input, answers) => {
    return {other: true}
})
.withAsk(ask)

The first argument to all of the ask functions is the name of the field. We suggest you make it a valid JS token, so you can reference it with dot notation in your transform function.

The second argument is a description. Use this field to give users the details they need to properly answer your questions and inform them of how this information will be used.

When calling forPrimitive, the 3rd argument is required and defines the type you must answer with. Right now boolean, string, and number are allowed. These types follow the same semantics as they do in the JSON Schema Spec.

When calling forGenerator, the 3rd argument is optional and allows you to filter the Lenses you can answer with to ones that render a certain Schema. There’s a one-to-many relationship between Schemas and Lenses, so often a Transformation will need to ask users which Lens they would like to render the result with. Naturally if you are Transforming something into a Rest Route, you only want to employ Lenses capable of doing that.

When calling for, the 3rd argument is a function that receives input model being transformed. You can process that input and return another JSON schema. This supports asking for data based on the input model.

Using Answers: All answers are added to an object which is passed into the transform function as the second argument. You can access them with dot or bracket notation as you would any other property.

ask.forPrimitive('ask1', 'Description here...', 'string')
ask.forLens('ask2', 'Description here...', 'test:package/Schema')
ask.forSchema('ask3', 'Description here...')
ask.for('ask4', 'Description here...', (input)=> {
  return {}
})

(input, answers) => {
  answers.ask1 //a primitive of the specified type
  answers.ask2 //the ID of a compiled Lens, guaranteed to render specified Schema
  answers.ask3 //a Schema reference
 ...
}

Advanced Options

It is possible to further customize the code that gets written when a relationship is evaluated. Just call Generate from your transform function. Generate takes 3 arguments: 1) the abstraction or generator id you want to generate code for 2) the JSON value for that generator and 3) the options below.

  • generatorId - allows you to control which generator is used to render this code. This provides degrees of freedom if you want to make the output generator conditional.
  • containers - allows you to nest generators together ie API Endpoint, with a query inside its handler. Use the container name as the key, and an array of Generate calls to populate them
  • variables - allow you to set the value of a variable. In your generator, you might have a variable for queryResult, and now you want to set all instances of that variable to insertUserResult.
  • tag - tags are used to uniquely identify children within the generated code. Even after manual changes are made, Optic will still know to keep these children in sync in addition to the parent.
(input, answers) => {
  const value = {
    key1: input.a
  }
  return Generate(answers.output, value, {
    generatorId: 'example:skill/generator',
    containers: {
      'container name': [Generate(...), Generate(...)],
    },
    variables: {
      'variable': 'value' 
    },
    tag: 'example',
  })
}

Here's a working example of a more complex relationship that yields a GET {X} API Endpoint from a mongoose-schema.

input:

export const ToDo = mongoose.model('Todo', new mongoose.Schema({
  task: 'string',
  isDone: 'boolean',
  dueDate: 'date'
}));

output:

app.get('/todo', (req, res) => {
    res.setHeader('Content-Type', 'application/json');
    ToDo.findOne({ _id: req.query.todoId }, function (err, item) {  //tag: query
      if (err) {
          res.send(400, err)
      } else {
        if (item) {
              res.status(200).send(item)
        } else {
              res.status(400).send(err)
        }
      }
    })
})

relationship:

export const getRouteFromSchema = Relationship(
	'Get Route',
	'get-route-from-schema',
	'mongoose-schema',
	'optic:rest/route',
	(input, answers) => {
		const routeName = input.name.toLowerCase();
		const idName = routeName+'Id'
		const route = "/" + routeName;
		const routeDescription = {
			method: "get",
			url: route,
			parameters: [{ in: 'query', name: idName}]
		};
		const queryDescription = {
			query: {
				'_id': Generate('optic:rest/parameter', {
					in: 'query',
					name: idName
				})
			}
		};
		return Generate(answers.output, routeDescription, {
			containers: {
				"handler": [Generate('optic:mongoose@0.3.0/find-one', queryDescription, {
					tag: "query",
					containers: {
						"found": [Generate('optic:rest/response', {code: 200}, {variables: {item: 'item'}})],
						"notFound": [Generate('optic:rest/response', {code: 404, value: 'Not Found'}, {variables: {item: 'item'}})],
						"error": [Generate('optic:rest/response', {code: 400}, {variables: {item: 'err'}})]
					}
				})]
			}
		});
	})

Next Steps