Browse Source

Update content negotiation

Unify returning of Accept-Post and Accept-Patch items.
master
TheoryOfNekomata 7 months ago
parent
commit
47a80ff411
8 changed files with 100 additions and 53 deletions
  1. +17
    -11
      packages/core/src/backend/servers/http/core.ts
  2. +9
    -9
      packages/core/src/backend/servers/http/handlers/default.ts
  3. +4
    -0
      packages/core/src/common/media-type.ts
  4. +11
    -0
      packages/core/src/common/resource.ts
  5. +18
    -16
      packages/core/test/handlers/http/default.test.ts
  6. +19
    -17
      packages/core/test/handlers/http/error-handling.test.ts
  7. +11
    -0
      packages/examples/cms-web-api/bruno/Check Allowed Post Operations.bru
  8. +11
    -0
      packages/examples/cms-web-api/bruno/Check Allowed Posts Operations.bru

+ 17
- 11
packages/core/src/backend/servers/http/core.ts View File

@@ -14,8 +14,10 @@ import {
BaseResourceType,
CanPatchSpec,
DELTA_SCHEMA,
getAcceptPatchString,
getAcceptPostString,
LanguageDefaultErrorStatusMessageKey,
PATCH_CONTENT_MAP_TYPE,
PATCH_CONTENT_MAP_TYPE, PATCH_CONTENT_TYPES,
PatchContentType,
Resource,
} from '../../../common';
@@ -235,22 +237,24 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
: charsetRaw.trim()
) ?? (isTextMediaType(mediaType) ? 'utf-8' : 'binary');

if (effectiveMethod === 'PATCH') {
const validPatchTypes = Object.entries(req.resource.state.canPatch)
.filter(([, allowed]) => allowed)
.map(([patchType]) => patchType);

const validPatchContentTypes = Object.entries(PATCH_CONTENT_MAP_TYPE)
.filter(([ patchType]) => validPatchTypes.includes(patchType))
.map(([contentType ]) => contentType);
if (effectiveMethod === 'POST' && PATCH_CONTENT_TYPES.includes(mediaType as PatchContentType)) {
throw new ErrorPlainResponse('invalidResource', {
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
res: theRes,
headers: {
'Accept-Post': getAcceptPostString(req.backend.app.mediaTypes),
},
});
}

if (effectiveMethod === 'PATCH') {
const isPatchEnabled = req.resource.state.canPatch[PATCH_CONTENT_MAP_TYPE[mediaType as PatchContentType]];
if (!isPatchEnabled) {
throw new ErrorPlainResponse('invalidResourcePatchType', {
statusCode: constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE,
res: theRes,
headers: {
'Accept-Patch': validPatchContentTypes.join(', '),
'Accept-Patch': getAcceptPatchString(req.resource.state.canPatch),
},
});
}
@@ -549,7 +553,9 @@ export const createServer = (backendState: BackendState, serverParams = {} as Cr
'Content-Language': language.name,
};
if (resourceReq.method === 'POST') {
headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys()).join(',');
headers['Accept-Post'] = Array.from(resourceReq.backend.app.mediaTypes.keys())
.filter((t) => !Object.keys(PATCH_CONTENT_MAP_TYPE).includes(t))
.join(',');
} else if (resourceReq.method === 'PATCH') {
headers['Accept-Patch'] = Array.from(Object.entries(PATCH_CONTENT_MAP_TYPE))
.filter(([, value]) => Object.keys(resourceReq.resource.state.canPatch).includes(value))


+ 9
- 9
packages/core/src/backend/servers/http/handlers/default.ts View File

@@ -2,7 +2,7 @@ import {constants} from 'http2';
import {AllowedMiddlewareSpecification, Middleware} from '../../../common';
import {LinkMap} from '../utils';
import {PlainResponse, ErrorPlainResponse} from '../response';
import {PATCH_CONTENT_MAP_TYPE} from '../../../../common';
import {getAcceptPatchString, getAcceptPostString} from '../../../../common';

export const handleGetRoot: Middleware = (req, res) => {
const { backend, basePath } = req;
@@ -47,17 +47,17 @@ export const handleOptions = (middlewares: AllowedMiddlewareSpecification[]): Mi
const headers: Record<string, string> = {
'Allow': allowedMethods.join(', '),
};

if (allowedMethods.includes('POST')) {
headers['Accept-Post'] = getAcceptPostString(req.backend.app.mediaTypes);
}

if (allowedMethods.includes('PATCH')) {
const validPatchTypes = Object.entries(req.resource.state.canPatch)
.filter(([, allowed]) => allowed)
.map(([patchType]) => patchType);

const validPatchContentTypes = Object.entries(PATCH_CONTENT_MAP_TYPE)
.filter(([, patchType]) => validPatchTypes.includes(patchType))
.map(([contentType ]) => contentType);
.filter(([, allowed]) => allowed);

if (validPatchContentTypes.length > 0) {
headers['Accept-Patch'] = validPatchContentTypes.join(', ');
if (validPatchTypes.length > 0) {
headers['Accept-Patch'] = getAcceptPatchString(req.resource.state.canPatch);
}
}
return new PlainResponse({


+ 4
- 0
packages/core/src/common/media-type.ts View File

@@ -16,3 +16,7 @@ export const PATCH_CONTENT_TYPES = [
] as const;

export type PatchContentType = typeof PATCH_CONTENT_TYPES[number];

export const getAcceptPostString = (mediaTypes: Map<string, MediaType>) => Array.from(mediaTypes.keys())
.filter((t) => !PATCH_CONTENT_TYPES.includes(t as PatchContentType))
.join(',');

+ 11
- 0
packages/core/src/common/resource.ts View File

@@ -170,3 +170,14 @@ export const resource = <ResourceType extends BaseResourceType = BaseResourceTyp
};

export type ResourceType<R extends Resource> = v.Output<R['schema']>;

export const getAcceptPatchString = (canPatch: CanPatchObject) => {
const validPatchTypes = Object.entries(canPatch)
.filter(([, allowed]) => allowed)
.map(([patchType]) => patchType);

return Object.entries(PATCH_CONTENT_MAP_TYPE)
.filter(([, patchType]) => validPatchTypes.includes(patchType))
.map(([contentType ]) => contentType)
.join(',');
}

+ 18
- 16
packages/core/test/handlers/http/default.test.ts View File

@@ -28,6 +28,8 @@ const ACCEPT_CHARSET = 'utf-8';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;

const prepareStatusMessage = (s: string) => s.replace(/\$RESOURCE/g, 'Piano');

describe('happy path', () => {
let Piano: Resource;
let app: Application;
@@ -125,7 +127,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourceCollectionFetched);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
@@ -143,7 +145,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourceCollectionFetched);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCollectionFetched));
});

it('returns options', async () => {
@@ -153,7 +155,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.provideOptions);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('GET');
expect(allowedMethods).toContain('HEAD');
@@ -187,7 +189,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourceFetched);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
if (typeof resData === 'undefined') {
expect.fail('Response body must be defined.');
@@ -204,7 +206,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourceFetched);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceFetched));
});

it('returns options', async () => {
@@ -214,7 +216,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.provideOptions);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('GET');
expect(allowedMethods).toContain('HEAD');
@@ -259,7 +261,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourceCreated);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/2`);

@@ -281,7 +283,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.provideOptions);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('POST');
});
@@ -327,7 +329,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.provideOptions);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('PATCH');
const acceptPatch = res.headers['accept-patch']?.split(',').map((s) => s.trim()) ?? [];
@@ -351,7 +353,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourcePatched);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
@@ -388,7 +390,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourcePatched);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourcePatched));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
@@ -438,7 +440,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_OK);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourceReplaced);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceReplaced));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));

if (typeof resData === 'undefined') {
@@ -470,7 +472,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_CREATED);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourceCreated);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceCreated));
expect(res.headers).toHaveProperty('content-type', expect.stringContaining(ACCEPT));
expect(res.headers).toHaveProperty('location', `${BASE_PATH}/pianos/${newId}`);

@@ -492,7 +494,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.provideOptions);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('PUT');
});
@@ -531,7 +533,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.resourceDeleted);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.resourceDeleted));
expect(res.headers).not.toHaveProperty('content-type');
expect(resData).toBeUndefined();
});
@@ -543,7 +545,7 @@ describe('happy path', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NO_CONTENT);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.provideOptions);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.provideOptions));
const allowedMethods = res.headers.allow?.split(',').map((s) => s.trim()) ?? [];
expect(allowedMethods).toContain('DELETE');
});


+ 19
- 17
packages/core/test/handlers/http/error-handling.test.ts View File

@@ -28,6 +28,8 @@ const ACCEPT_CHARSET = 'utf-8';
const CONTENT_TYPE_CHARSET = 'utf-8';
const CONTENT_TYPE = ACCEPT;

const prepareStatusMessage = (s: string) => s.replace(/\$RESOURCE/g, 'Piano');

describe('error handling', () => {
let Piano: Resource;
let app: Application;
@@ -123,7 +125,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection));
});

it('throws on HEAD method', async () => {
@@ -137,7 +139,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResourceCollection));
});
});

@@ -161,7 +163,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToFetchResource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
});

it('throws on HEAD method', async () => {
@@ -175,7 +177,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToFetchResource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
});

it('throws on item not found', async () => {
@@ -234,7 +236,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToAssignIdFromResourceDataSource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToAssignIdFromResourceDataSource));
});

it('throws on error creating resource', async () => {
@@ -253,7 +255,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToCreateResource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToCreateResource));
});
});

@@ -285,7 +287,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToFetchResource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
Piano.canPatch(false);
});

@@ -305,7 +307,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.patchNonExistingResource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.patchNonExistingResource));
Piano.canPatch(false);
});

@@ -333,7 +335,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.invalidResourcePatchType);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatchType));
});
});

@@ -357,7 +359,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.invalidResourcePatchType);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatchType));
});

it('throws on operating with a delta to an attribute outside the schema', async () => {
@@ -377,7 +379,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.invalidResourcePatch);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch));
});

it('throws on operating a delta with mismatched value type', async () => {
@@ -397,7 +399,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.invalidResourcePatch);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch));
});

it('throws on performing an invalid delta', async () => {
@@ -417,7 +419,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_UNPROCESSABLE_ENTITY);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.invalidResourcePatch);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.invalidResourcePatch));
});
});
});
@@ -459,7 +461,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToEmplaceResource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToEmplaceResource));
});
});

@@ -490,7 +492,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToFetchResource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToFetchResource));
});

it('throws on item not found', async () => {
@@ -504,7 +506,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_NOT_FOUND);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.deleteNonExistingResource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.deleteNonExistingResource));
});

it('throws on unable to delete item', async () => {
@@ -522,7 +524,7 @@ describe('error handling', () => {
});

expect(res).toHaveProperty('statusCode', constants.HTTP_STATUS_INTERNAL_SERVER_ERROR);
expect(res).toHaveProperty('statusMessage', TEST_LANGUAGE.statusMessages.unableToDeleteResource);
expect(res).toHaveProperty('statusMessage', prepareStatusMessage(TEST_LANGUAGE.statusMessages.unableToDeleteResource));
});
});
});

+ 11
- 0
packages/examples/cms-web-api/bruno/Check Allowed Post Operations.bru View File

@@ -0,0 +1,11 @@
meta {
name: Check Allowed Post Operations
type: http
seq: 10
}

options {
url: http://localhost:6969/api/posts/9ba60691-0cd3-4e8a-9f44-e92b19fcacbc
body: none
auth: none
}

+ 11
- 0
packages/examples/cms-web-api/bruno/Check Allowed Posts Operations.bru View File

@@ -0,0 +1,11 @@
meta {
name: Check Allowed Posts Operations
type: http
seq: 11
}

options {
url: http://localhost:6969/api/posts
body: none
auth: none
}

Loading…
Cancel
Save