Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ version: '3'
services:
# Update this to the name of the service you want to work with in your docker-compose.yml file
devbox:
image: node:12.16.3-alpine3.11
ports:
- 3000:3000
image: node:12.16.3-alpine3.11

tmpfs:
- /workspace/node_modules:exec
- /workspace/examples/node_modules:exec

volumes:
# Update this to wherever you want VS Code to mount the folder of your project
Expand Down
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ dist
es
lib
rollup.config.js
jest.config.js
.eslintrc.js
2 changes: 1 addition & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"useTabs": false,
"printWidth": 120,
"printWidth": 90,
"tabWidth": 2,
"singleQuote": false,
"trailingComma": "all",
Expand Down
15 changes: 1 addition & 14 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"name": "Debug Basic Example",
"request": "launch",
"program": "${workspaceRoot}/examples/node_modules/.bin/moapp",
"args": ["serve", "-c", "examples/configs/basic.json"],
"outFiles": ["${workspaceRoot}/examples/react-app/lib/**/*.js"],
"cwd": "${workspaceRoot}",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"preLaunchTask": "build"
},
{
"name": "Tests",
"type": "node",
"request": "launch",
"program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js",
"stopOnEntry": false,
"args": ["--runInBand", "--no-cache"],
"args": ["--runInBand", "--no-cache", "-t", "'Not Supported Annotations Test'"],
"cwd": "${workspaceRoot}",
"preLaunchTask": null,
"runtimeExecutable": null,
Expand Down
9 changes: 5 additions & 4 deletions jest.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"coverageReporters": ["cobertura", "lcov", "text"],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
"branches": 90,
"functions": 90,
"lines": 90,
"statements": 90
}
},
"moduleDirectories": ["node_modules", "src"],
"moduleFileExtensions": ["ts", "js"],
"testRegex": "/test/.*\\.spec\\.(ts|js)$",
"testResultsProcessor": "jest-sonar-reporter",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"rollup-plugin-terser": "^6.1.0",
"rollup-plugin-typescript2": "^0.27.1",
"ts-jest": "^26.1.0",
"ts-transformer-keys": "^0.4.1",
"typescript": "^3.9.5"
},
"dependencies": {
Expand Down
10 changes: 6 additions & 4 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import pkg from "./package.json";

const extensions = [".ts"];
const noDeclarationFiles = { compilerOptions: { declaration: false } };
const tsconfig = "tsconfig.build.json";

const babelRuntimeVersion = pkg.dependencies["@babel/runtime"].replace(/^[^0-9]*/, "");

Expand All @@ -33,7 +34,7 @@ export default [
nodeResolve({
extensions,
}),
typescript({ useTsconfigDeclarationDir: true }),
typescript({ useTsconfigDeclarationDir: true, tsconfig }),
babel({
extensions,
plugins: [["@babel/plugin-transform-runtime", { version: babelRuntimeVersion }]],
Expand All @@ -57,6 +58,7 @@ export default [
}),
typescript({
tsconfigOverride: {
tsconfig,
compilerOptions: {
declaration: false,
module: "es2015",
Expand Down Expand Up @@ -84,7 +86,7 @@ export default [
replace({
"process.env.NODE_ENV": JSON.stringify("production"),
}),
typescript({ tsconfigOverride: noDeclarationFiles }),
typescript({ tsconfigOverride: noDeclarationFiles, tsconfig }),
babel({
extensions,
exclude: "node_modules/**",
Expand Down Expand Up @@ -114,7 +116,7 @@ export default [
nodeResolve({
extensions,
}),
typescript({ tsconfigOverride: noDeclarationFiles }),
typescript({ tsconfigOverride: noDeclarationFiles, tsconfig }),
babel({
extensions,
exclude: "node_modules/**",
Expand All @@ -139,7 +141,7 @@ export default [
nodeResolve({
extensions,
}),
typescript({ tsconfigOverride: noDeclarationFiles }),
typescript({ tsconfigOverride: noDeclarationFiles, tsconfig }),
babel({
extensions,
exclude: "node_modules/**",
Expand Down
60 changes: 60 additions & 0 deletions src/odata-annotations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ODataAnnotations } from "./odata";

/*
* http://docs.oasis-open.org/odata/odata-json-format/v4.0/os/odata-json-format-v4.0-os.html#_Instance_Annotations
* Annotations are name/value pairs that have a dot (.) as part of the name.
* $odata.context - system annotations (supported)
* @com.contoso - custom annotation
* com.contoso - also valid custom annotation
* [email protected] - scoped system annotations
* [email protected] - scoped custom annotations
*/
export function getAnnotations<K extends { [key: string]: string }>(
obj: K,
): ODataAnnotations {
const annotations: ODataAnnotations = {};
const receivedKeys = Object.getOwnPropertyNames(obj);
const defaultNamespaces = ["$odata", "odata"];
const namespaceDelimiter = ".";
const propertyDelimiter = "@";
const getSubContainer = (subKey: string) => {
if (!annotations[subKey]) annotations[subKey] = {};
return annotations[subKey] as ODataAnnotations;
};
for (const key of receivedKeys) {
const parts = key.split(namespaceDelimiter);
if (parts.length <= 1)
// not an annotation. Should contain '.' (dot)
continue;

// at this point it is defintely annotation so delete it from original object
const value = obj[key];
delete obj[key];

const prefix = parts[0]; // get left part and split it
const prefixParts = prefix.split(propertyDelimiter);

// Annotation in the form of @[email protected] is not supported
// It will be added to root annotation object as is
if (prefixParts.length > 2) {
annotations[key] = value;
continue;
}

// at this point prefixParts.length is 1 or 2
const namespace = prefixParts[prefixParts.length - 1];
const container =
prefixParts.length === 1 ? annotations : getSubContainer(prefixParts[0]);

// multiple dots considered custom annotation and will be added as is preserving
// the namespace
let annotationKey = parts.length == 2 ? parts[1] : parts.slice(1).join(".");
if (defaultNamespaces.indexOf(namespace) === -1)
annotationKey = `${namespace}.${annotationKey}`;

// set annotation
container[annotationKey] = value;
}

return annotations;
}
68 changes: 48 additions & 20 deletions src/odata-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { Entity, ODataOperations, ODataConfig, ODataResponse, Predicate, CountPredicate } from "odata";
import {
Entity,
ODataOperations,
ODataConfig,
Predicate,
CountPredicate,
Annotated,
ODataError,
} from "odata";
import { ODataQueryWrapper } from "./odata-query";
import { getAnnotations } from "./odata-annotations";

export class ODataClient<T extends Entity> implements ODataOperations<T> {
private url: string;
Expand All @@ -8,20 +17,21 @@ export class ODataClient<T extends Entity> implements ODataOperations<T> {
this.url = `${this.config.baseUrl}/${this.resource}`;
}

public add(entity: T): Promise<ODataResponse<T>> {
public add(entity: T): Promise<Annotated<T>> {
const options: RequestInit = this.prepareEntityRequest("POST", entity);
return this.fetch(this.url, options);
return this.fetchAnnotated(this.url, options);
}

public update(entity: T): Promise<ODataResponse<T>> {
public update(entity: T): Promise<Annotated<T>> {
const options: RequestInit = this.prepareEntityRequest("PUT", entity);
return this.fetch(this.url, options);
return this.fetchAnnotated(this.url, options);
}

public async patch(entityId: string, entity: Partial<T>): Promise<void> {
const url = `${this.url}/${entityId}`;
const options: RequestInit = this.prepareEntityRequest("PATCH", entity);
await this.config.http.fetch(url, options);
const response = await this.config.http.fetch(url, options);
this.processResponse(response);
}

public async delete(entityId: string): Promise<void> {
Expand All @@ -34,22 +44,23 @@ export class ODataClient<T extends Entity> implements ODataOperations<T> {
await this.config.http.fetch(url, options);
}

public get(entityId: string): Promise<ODataResponse<T>> {
public get(entityId: string): Promise<Annotated<T>> {
const url = `${this.url}/${entityId}`;
const options: RequestInit = this.prepareEntityRequest("GET");
return this.fetch(url, options);
return this.fetchAnnotated(url, options);
}

query(predicate?: Predicate<T> | void): Promise<ODataResponse<T[]>> {
query(predicate?: Predicate<T> | void): Promise<Annotated<T[]>> {
let url = this.url;
if (predicate) {
const query = new ODataQueryWrapper();
predicate(query);
url += query.get();
}
const options = this.prepareEntityRequest("GET");
return this.fetch(url, options);
return this.fetchAnnotated(url, options, "value");
}

public async count(predicate?: CountPredicate<T> | void): Promise<number> {
let url = `${this.url}/$count`;
if (predicate) {
Expand All @@ -62,17 +73,25 @@ export class ODataClient<T extends Entity> implements ODataOperations<T> {
return parseInt(await response.text());
}

private async parse<K>(response: Response): Promise<ODataResponse<K>> {
const text = await response.text();
const parsed = JSON.parse(text, this.config.jsonParseReviver);
private async processResponse(response: Response): Promise<string> {
if (response.ok) {
return response.status !== 204 ? await response.text() : "";
}

return {
value: parsed.value,
annotations: {},
};
const content = await response.text();

if (content) {
const error = JSON.parse(content);
throw new ODataError(error);
}

throw new Error(response.statusText);
}

private prepareEntityRequest<TEntity>(method: string, entity: TEntity | undefined = undefined): RequestInit {
private prepareEntityRequest<TEntity>(
method: string,
entity: TEntity | undefined = undefined,
): RequestInit {
const body = entity ? JSON.stringify(entity) : undefined;
const options: RequestInit = {
body,
Expand All @@ -84,8 +103,17 @@ export class ODataClient<T extends Entity> implements ODataOperations<T> {
return options;
}

private async fetch<K>(url: string, options: RequestInit): Promise<ODataResponse<K>> {
private async fetchAnnotated<K>(
url: string,
options: RequestInit,
fragment: string | undefined = undefined,
): Promise<Annotated<K>> {
const response = await this.config.http.fetch(url, options);
return await this.parse(response);
const text = await this.processResponse(response);
const parsed = JSON.parse(text, this.config.jsonParseReviver);

const result: Annotated<K> = fragment ? parsed[fragment] : parsed;
result.$odata = getAnnotations(parsed);
return result;
}
}
6 changes: 4 additions & 2 deletions src/odata-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ export function oData(config: Partial<ODataConfig>): DataContext {
baseUrl: config.baseUrl ?? "",
http: config.http ? config.http : <never>window,
jsonParseReviver: config.jsonParseReviver,
defaultContentType: config.defaultContentType ?? "application/json; odata.metadata=minimal",
defaultContentType:
config.defaultContentType ?? "application/json; odata.metadata=minimal",
};

return <T extends Entity>(resource: string) => new ODataClient<T>(odataConfig, resource);
return <T extends Entity>(resource: string) =>
new ODataClient<T>(odataConfig, resource);
}
8 changes: 4 additions & 4 deletions src/odata-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,14 @@ export class ODataQueryWrapper<T> implements ODataQuery<T>, ODataCountQuery<T> {
}

public get(): string {
const params = new URLSearchParams("?");
const paramsDict: string[] = [];
for (const key in this.query) {
if (Object.prototype.hasOwnProperty.call(this.query, key)) {
const value = this.query[key]?.toString() ?? "";
if (key) params.append(key, value);
const value = encodeURIComponent(this.query[key]?.toString() ?? "");
if (key) paramsDict.push(`${key}=${value}`);
}
}
const str = params.toString().replace("%24", "$");
const str = paramsDict.join("&");
return str ? "?" + str : "";
}
}
Loading