TiFShared
October 12, 2024 ยท View on GitHub
An extended standard library used at TiF.
Usage
Make sure you import "TiFShared" in the root of any application you use this in. This ensures that all pollyfills and extensions are applied correctly.
import { thing } from "some-other-library"
import "TiFShared"
// Code to launch the app.
Overview
This library contains 2 types of code:
- Highly generic code.
- Known math formulas, string manipulation functions, common utility functions, etc.
- Shared Domain Specific Models/Frameworks
- API Client, Logging, etc.
The first type of code can be found in the lib folder, and the second type of code can be found in the other folders of this library. Here's an overview of each folder in the library:
| Folder | Overview |
|---|---|
| lib | Contains common utility functions/highly generic code. |
| domain-models | Application specific shared models. |
| api | API Client + Associated API Models. |
| logging | A shared custom logging framework. |
When and where should I add code to this library?
Since there a 2 types of shared code, there are naturally a few questions you'll need to ask yourself.
Does the code target our primary problem domain?
- Yes
- Does the code need to be shared, or should it belong on an already shared function/file/class/type?
- Yes
- Should it belong to a shared function/class/file/type?
- Yes
- Put the code in the function/class/file/type.
- No
- Is the code a new significant component (logging, API Client, etc.)?
- Yes
- Put the code in a new folder in the root of this library.
- No
- Put the code in the proper folder.
- Yes
- Is the code a new significant component (logging, API Client, etc.)?
- Yes
- Should it belong to a shared function/class/file/type?
- No
- Don't put the code in this library.
- Yes
- Does the code need to be shared, or should it belong on an already shared function/file/class/type?
- No
- Is the code super generic (like a common utility function)?
- Yes
- Put it in the appropriate place inside the lib folder (create a new file if neccessary).
- No
- Don't put the code in this library.
- Yes
- Is the code super generic (like a common utility function)?
Core-Components
Here is an outline of some of the core components in this library.
Extensions
Many languages contain the ability to add properties to already existing types retroactively. For instance, in swift:
extension Numeric {
var squared: Self { self * self }
}
let value = 2.squared // value == 4
This can also be done in typescript, however it's usually looked down upon due to the possibility of accidentially overwriting extension properties. For instance, say we have 2 libraries that extend the Array prototype:
// 3rd-Party Library
Array.prototype.foo = function () {
// Code...
}
// Extensing the prototype allows the library to do this.
const arr = [].foo().foo().foo()
// Our code
Array.prototype.foo = function () {
// BAD! We have now just broken foo from the 3rd party.
}
const foo = (arr: Array) => {
// Safe, we have our own function.
}
const arr = foo(foo(foo([])))
Despite this downside, there are plenty of reasons to want extensions:
- Better discoverability
- We don't need to know that a global
foofunction exists.
- We don't need to know that a global
- Retroactive interface conformances
- We can add extensions on an already existing type to make it conform to an interface that we define.
The extensions system in this library provides this benefit with the proper safety net. To add extension properties on an existing class, use the prototypeExtension function:
class Thing {
foo() {}
}
prototypeExtension(Thing, { bar: (t) => t.foo() })
const thing = new Thing()
thing.foo() // Existing property is maintained.
thing.ext.bar() // All extensions are accessed via ext.
// Throws an error, bar is already on Thing.
prototypeExtension(Thing, { bar: () => "Nice" })
// Throws an error, foo is already on Thing.
prototypeExtension(Thing, { foo: () => "Nice" })
class Thing2 {
ext() {}
}
// Throws an error, ext is already on Thing2.
prototypeExtension(Thing2, { foo: () => {} })
You can also do this on normal objects via extension:
const obj = { hello: () => "world" }
const extended = extension(obj, { world: () => "hello" })
extended.hello() // Existing property is maintained
extended.world()
// Throws an error, hello already exists on extended
extension(extended, { hello: () => 1 })
Logging
We use a shared logging framework to allow logging to multiple destinations.
const log = logger("tif.read.me") // Each logger is assigned a label.
addLogHandler(consoleLogHandler()) // Enable console logging.
// Log messages can include additional metadata passed as an object.
log.info("Hello world", { meta: "data" })
It's possible to add multiple log destinations by creating a function that conforms to LogHandler and adding it via addLogHandler.
const customLogHandler = (
label: string,
level: LogLevel,
message: string,
metadata: object
) => {
// Send these parameters somewhere
}
addLogHandler(customLogHandler)
The available log levels are info, warn, trace, debug, and error. Each of these levels are functions on the object returned from logger.
API
Our API Schema is codified in api\TiFAPISchema.ts and details the shape of inputs and outputs for all endpoints.
Endpoints conform to this pattern:
{
[endpointName]: {
input: {
body?: ZodSchema,
query?: ZodSchema,
params?: ZodSchema
},
outputs: { //needs at least 1 output
status200?: ZodSchema,
status201?: ZodSchema,
status204?: "no-content",
...etc
},
constraints: (input, output) => boolean, //throws a validation error if the function returns false (eg. checking if the input value matches the output value)
httpRequest: {
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
endpoint: "/{string}"
}
},
}
Adding Support for a new Endpoint
Support for new endpoints can be added to TiFAPISchema like so:
const TiFAPISchema = {
// Other endpoints...
myNewEndpoint: endpointSchema({
input: {
params: {
id: z.number()
}
},
outputs: {
status200: z.object({ value: z.string() }),
status400: z.object({ other: z.number() })
},
httpRequest: {
method: "POST",
endpoint: "/my/new/endpoint/:id"
}
})
// Other endpoints...
}
This is how they can be used, using the example endpoint we just added:
const foo = async (api: TiFAPI) => {
const resp = await api.myNewEndpoint({params: {id: 1}}) // Typescript will confirm the shape of the request
if (resp.status === 200) {
// Type inferred to be the converted type of the schema for status200
console.log(resp.data.value)
} else {
// Type inferred to be the converted type of the schema for status400
console.log(resp.data.other)
}
}
API Clients
The TiFAPISchema endpoints are converted to function types and stored in the TiFAPI type.
API clients can be created using the TiFAPIClientCreator() function or by reducing the TiFAPISchema.
TiFAPIClientCreator()
- Creates a typesafe TiFAPI client with methods whose signatures match the endpoints described by the
TiFAPISchema.
APIMiddleware
- Can be passed to
TiFAPIClientCreator()to handle or transform the high-level requests and responses of theTiFAPIClient.
2a. TiFAPITransport
- An instance of
APIMiddlewareresponsible for sending and retrieving data across the network viafetch.
- API Extensions
- By default the API endpoint functions created using
TiFAPIClientCreator()take in the requests described by the TiFAPISchema and can access additional contextual information like the endpoint name and endpoint schema itself. Additional contextual information can be inserted throughAPIMiddlewareor requested by adding a type parameter toTiFAPIClientCreator().
An example API client can be constructed as follows:
const apiClient = TiFAPIClientCreator<{signal?: AbortSignal}>(
validateAPIClientCall,
jwtMiddleware(async () => "My JWT token"), // inserts "{headers}" into the context, which can be accessed by subsequent middlewares.
tifAPITransport(new URL("https://api.production.com"))
)
const controller = new AbortController()
apiClient.myNewEndpoint({params: {id: 1}, signal: controller.signal}) // apiClient now allows an AbortSignal to be passed for all endpoints in addition to the standard input.
API Testing
The mockTiFServer() and mockTiFEndpoint() test helpers can be used to quickly mock the TiF API and assert expected requests.
An example API mock can be constructed as follows:
test("endpoint", async () => {
mockTiFServer({
myNewEndpoint: {
expectedRequest: { params: { id: 2 } },
mockResponse: {
status: 200,
data: { trackableRegions: EXPECTED_ARRIVAL_REGIONS }
}
}
})
testAPI.myNewEndpoint({ params: { id: 1 } }) // will throw an expect error
})
test("endpoint", async () => {
mockTiFEndpoint("myNewEndpoint", 404, { error: "event-not-found" })
const response = await testAPI.myNewEndpoint({ params: { id: 1 } }) // response = { status: 404, { error: "event-not-found" } }
})
Local Development Setup
For developers working on the TiFShared package and test changes locally without republishing, the npm link command can be used. This command creates a symlink that points to the local development version of the package. Follow these steps to set up your development environment:
Step 1: Link the Package Globally
Ensure the TiFShared package is installed locally, then navigate to the root directory of TiFShared:
cd TiFShared
Run the following command to create a global symlink:
npm link
This command makes the TiFShared package globally available on your machine, akin to having it installed as a global package.
Step 2: Link the Package to Your Main Project
With the utils package globally linked, navigate to the root directory of your main project, i.e.:
cd ../FitnessProject
Then link the utils package to your main project:
npm link TiFShared
- Important: Package name is case-sensitive
Step 3: Verify the Link
To ensure the symlink was successfully created, you can check in the node_modules directory of your main project:
ls -l node_modules/your-utils-package-name
This command should show a link pointing to your utils package directory.
Step 4: Using the Package
You can now use the utils package in your main project as if it were installed through npm:
import "TiFShared";
Step 5: Unlink When Done
When you no longer need the local development link, or if you wish to revert to the official version of the package:
Navigate back to your main project directory:
cd ../FitnessProject
Unlink the utils package:
npm unlink --no-save TiFShared
Or, go to the utils package directory to remove the global link:
cd path/to/your-utils-package
npm unlink
This will remove the global symlink and any project-specific links, effectively decoupling the local development setup. Afterwards, you can npm install the remote version of the package if needed.
Support + Contributing
Reach out on to Sean or Matthew on slack if you have any further questions, especially around whether or not to include code in this repo.