@fnd-platform/api 1.0.0-alpha.1 → 1.0.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api-project.d.ts +76 -64
- package/lib/api-project.d.ts.map +1 -1
- package/lib/api-project.js +597 -1
- package/lib/api-project.js.map +1 -1
- package/lib/handlers/content.d.ts +25 -9
- package/lib/handlers/content.d.ts.map +1 -1
- package/lib/handlers/content.js +270 -52
- package/lib/handlers/content.js.map +1 -1
- package/lib/handlers/health.js +10 -10
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -1
- package/lib/index.js.map +1 -1
- package/lib/lib/errors.js +56 -62
- package/lib/lib/keys.d.ts +122 -0
- package/lib/lib/keys.d.ts.map +1 -0
- package/lib/lib/keys.js +116 -0
- package/lib/lib/keys.js.map +1 -0
- package/lib/middleware/auth.js +25 -25
- package/lib/middleware/cors.js +29 -34
- package/lib/middleware/error-handler.js +22 -21
- package/lib/middleware/index.js +14 -44
- package/lib/middleware/logging.js +26 -30
- package/lib/middleware/validation.js +30 -29
- package/lib/options.js +3 -3
- package/package.json +13 -11
- package/LICENSE +0 -21
package/lib/api-project.js
CHANGED
|
@@ -81,7 +81,6 @@ class FndApiProject extends projen_1.typescript.TypeScriptProject {
|
|
|
81
81
|
rootDir: 'src',
|
|
82
82
|
declaration: true,
|
|
83
83
|
declarationMap: true,
|
|
84
|
-
sourceMap: true,
|
|
85
84
|
strict: true,
|
|
86
85
|
esModuleInterop: true,
|
|
87
86
|
skipLibCheck: true,
|
|
@@ -99,6 +98,8 @@ class FndApiProject extends projen_1.typescript.TypeScriptProject {
|
|
|
99
98
|
this.dynamodbEnabled = options.dynamodb ?? true;
|
|
100
99
|
this.cognitoEnabled = options.cognito ?? true;
|
|
101
100
|
this.corsEnabled = options.cors ?? true;
|
|
101
|
+
// Add @fnd-platform/api to parent's devDependencies (for .projenrc.ts imports)
|
|
102
|
+
this.parentProject.addDevDeps('@fnd-platform/api@^1.0.0-alpha.1');
|
|
102
103
|
// Add dependencies
|
|
103
104
|
this.addApiDependencies();
|
|
104
105
|
// Generate directory structure and files
|
|
@@ -125,6 +126,12 @@ class FndApiProject extends projen_1.typescript.TypeScriptProject {
|
|
|
125
126
|
new projen_1.SampleFile(this, 'src/handlers/health.ts', {
|
|
126
127
|
contents: this.getHealthHandlerTemplate(),
|
|
127
128
|
});
|
|
129
|
+
// Content CRUD handler - included when DynamoDB is enabled
|
|
130
|
+
if (this.dynamodbEnabled) {
|
|
131
|
+
new projen_1.SampleFile(this, 'src/handlers/content.ts', {
|
|
132
|
+
contents: this.getContentHandlerTemplate(),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
128
135
|
}
|
|
129
136
|
/**
|
|
130
137
|
* Generates the lib directory with utility files.
|
|
@@ -138,6 +145,16 @@ class FndApiProject extends projen_1.typescript.TypeScriptProject {
|
|
|
138
145
|
new projen_1.SampleFile(this, 'src/lib/errors.ts', {
|
|
139
146
|
contents: this.getErrorsTemplate(),
|
|
140
147
|
});
|
|
148
|
+
// Request utilities
|
|
149
|
+
new projen_1.SampleFile(this, 'src/lib/request.ts', {
|
|
150
|
+
contents: this.getRequestTemplate(),
|
|
151
|
+
});
|
|
152
|
+
// DynamoDB key builders (included when DynamoDB is enabled)
|
|
153
|
+
if (this.dynamodbEnabled) {
|
|
154
|
+
new projen_1.SampleFile(this, 'src/lib/keys.ts', {
|
|
155
|
+
contents: this.getKeysTemplate(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
141
158
|
// Middleware utilities (stub for Sprint 03)
|
|
142
159
|
new projen_1.SampleFile(this, 'src/lib/middleware.ts', {
|
|
143
160
|
contents: this.getMiddlewareTemplate(),
|
|
@@ -661,6 +678,585 @@ export interface UserEntity extends BaseEntity {
|
|
|
661
678
|
}
|
|
662
679
|
|
|
663
680
|
// Add your custom entity types below
|
|
681
|
+
`;
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Returns the request utilities template content.
|
|
685
|
+
*/
|
|
686
|
+
getRequestTemplate() {
|
|
687
|
+
return `import type { APIGatewayProxyEvent } from 'aws-lambda';
|
|
688
|
+
import { ValidationError, UnauthorizedError } from './errors';
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* API Gateway event with Cognito authorizer claims.
|
|
692
|
+
*/
|
|
693
|
+
interface AuthenticatedEvent extends APIGatewayProxyEvent {
|
|
694
|
+
requestContext: APIGatewayProxyEvent['requestContext'] & {
|
|
695
|
+
authorizer: {
|
|
696
|
+
claims: {
|
|
697
|
+
sub: string;
|
|
698
|
+
email: string;
|
|
699
|
+
'cognito:groups'?: string[];
|
|
700
|
+
};
|
|
701
|
+
};
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Parse JSON body from API Gateway event.
|
|
707
|
+
*
|
|
708
|
+
* @param event - API Gateway proxy event
|
|
709
|
+
* @returns Parsed JSON body as type T
|
|
710
|
+
* @throws {ValidationError} If body is missing or invalid JSON
|
|
711
|
+
*/
|
|
712
|
+
export function parseBody<T>(event: APIGatewayProxyEvent): T {
|
|
713
|
+
if (!event.body) {
|
|
714
|
+
throw new ValidationError('Request body is required');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
return JSON.parse(event.body) as T;
|
|
719
|
+
} catch {
|
|
720
|
+
throw new ValidationError('Invalid JSON in request body');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Get a required path parameter from the event.
|
|
726
|
+
*
|
|
727
|
+
* @param event - API Gateway proxy event
|
|
728
|
+
* @param name - Name of the path parameter
|
|
729
|
+
* @returns The path parameter value
|
|
730
|
+
* @throws {ValidationError} If the parameter is missing
|
|
731
|
+
*/
|
|
732
|
+
export function requirePathParam(event: APIGatewayProxyEvent, name: string): string {
|
|
733
|
+
const value = event.pathParameters?.[name];
|
|
734
|
+
if (!value) {
|
|
735
|
+
throw new ValidationError(\`Missing path parameter: \${name}\`);
|
|
736
|
+
}
|
|
737
|
+
return value;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Get an optional query parameter from the event.
|
|
742
|
+
*
|
|
743
|
+
* @param event - API Gateway proxy event
|
|
744
|
+
* @param name - Name of the query parameter
|
|
745
|
+
* @param defaultValue - Optional default value if parameter is missing
|
|
746
|
+
* @returns The query parameter value or default
|
|
747
|
+
*/
|
|
748
|
+
export function getQueryParam(
|
|
749
|
+
event: APIGatewayProxyEvent,
|
|
750
|
+
name: string,
|
|
751
|
+
defaultValue?: string
|
|
752
|
+
): string | undefined {
|
|
753
|
+
return event.queryStringParameters?.[name] ?? defaultValue;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Get user ID from an authenticated request.
|
|
758
|
+
*
|
|
759
|
+
* @param event - Authenticated API Gateway event
|
|
760
|
+
* @returns The user ID from the JWT token
|
|
761
|
+
* @throws {UnauthorizedError} If user ID is not found in the token
|
|
762
|
+
*/
|
|
763
|
+
export function getUserId(event: AuthenticatedEvent): string {
|
|
764
|
+
const userId = event.requestContext.authorizer?.claims?.sub;
|
|
765
|
+
if (!userId) {
|
|
766
|
+
throw new UnauthorizedError('User ID not found in token');
|
|
767
|
+
}
|
|
768
|
+
return userId;
|
|
769
|
+
}
|
|
770
|
+
`;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Returns the DynamoDB key builders template content.
|
|
774
|
+
*/
|
|
775
|
+
getKeysTemplate() {
|
|
776
|
+
return `/**
|
|
777
|
+
* DynamoDB key builders for single-table design.
|
|
778
|
+
*
|
|
779
|
+
* These helpers generate consistent key structures for content entities
|
|
780
|
+
* following the fnd-platform single-table design patterns.
|
|
781
|
+
*/
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Key structure for DynamoDB primary key.
|
|
785
|
+
*/
|
|
786
|
+
export interface PrimaryKey {
|
|
787
|
+
PK: string;
|
|
788
|
+
SK: string;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Key structure for GSI1 (slug lookup).
|
|
793
|
+
*/
|
|
794
|
+
export interface GSI1Key {
|
|
795
|
+
GSI1PK: string;
|
|
796
|
+
GSI1SK: string;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Key structure for GSI2 (type/status listing).
|
|
801
|
+
*/
|
|
802
|
+
export interface GSI2Key {
|
|
803
|
+
GSI2PK: string;
|
|
804
|
+
GSI2SK: string;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Content key builders for DynamoDB operations.
|
|
809
|
+
*/
|
|
810
|
+
export const contentKeys = {
|
|
811
|
+
/**
|
|
812
|
+
* Generate partition key for content item.
|
|
813
|
+
*/
|
|
814
|
+
pk: (id: string): string => \`CONTENT#\${id}\`,
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Generate sort key for content item.
|
|
818
|
+
*/
|
|
819
|
+
sk: (): string => 'CONTENT',
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Generate full primary key for content item.
|
|
823
|
+
*/
|
|
824
|
+
keys: (id: string): PrimaryKey => ({
|
|
825
|
+
PK: contentKeys.pk(id),
|
|
826
|
+
SK: contentKeys.sk(),
|
|
827
|
+
}),
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* GSI1 key builders for slug-based lookups.
|
|
831
|
+
*/
|
|
832
|
+
gsi1: {
|
|
833
|
+
/**
|
|
834
|
+
* Generate GSI1 keys for looking up content by slug.
|
|
835
|
+
*/
|
|
836
|
+
bySlug: (slug: string): GSI1Key => ({
|
|
837
|
+
GSI1PK: \`CONTENT#SLUG#\${slug}\`,
|
|
838
|
+
GSI1SK: 'CONTENT',
|
|
839
|
+
}),
|
|
840
|
+
},
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* GSI2 key builders for listing content by type.
|
|
844
|
+
*/
|
|
845
|
+
gsi2: {
|
|
846
|
+
/**
|
|
847
|
+
* Generate GSI2 keys for listing content by type with timestamp sorting.
|
|
848
|
+
*/
|
|
849
|
+
byType: (contentType: string, timestamp: string): GSI2Key => ({
|
|
850
|
+
GSI2PK: contentType,
|
|
851
|
+
GSI2SK: timestamp,
|
|
852
|
+
}),
|
|
853
|
+
},
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* User key builders for DynamoDB operations.
|
|
858
|
+
*/
|
|
859
|
+
export const userKeys = {
|
|
860
|
+
/**
|
|
861
|
+
* Generate partition key for user.
|
|
862
|
+
*/
|
|
863
|
+
pk: (id: string): string => \`USER#\${id}\`,
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Sort key generators for user-related items.
|
|
867
|
+
*/
|
|
868
|
+
sk: {
|
|
869
|
+
/**
|
|
870
|
+
* Generate sort key for user profile.
|
|
871
|
+
*/
|
|
872
|
+
profile: (): string => 'PROFILE',
|
|
873
|
+
},
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Generate full primary key for user profile.
|
|
877
|
+
*/
|
|
878
|
+
keys: (id: string): PrimaryKey => ({
|
|
879
|
+
PK: userKeys.pk(id),
|
|
880
|
+
SK: userKeys.sk.profile(),
|
|
881
|
+
}),
|
|
882
|
+
};
|
|
883
|
+
`;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Returns the content handler template content.
|
|
887
|
+
*/
|
|
888
|
+
getContentHandlerTemplate() {
|
|
889
|
+
return `/**
|
|
890
|
+
* Content CRUD handlers with DynamoDB operations.
|
|
891
|
+
*
|
|
892
|
+
* Implements full CRUD operations for content entities using
|
|
893
|
+
* single-table design patterns with GSI support for efficient queries.
|
|
894
|
+
*/
|
|
895
|
+
|
|
896
|
+
import type { APIGatewayProxyHandler, APIGatewayProxyEvent } from 'aws-lambda';
|
|
897
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
898
|
+
import {
|
|
899
|
+
DynamoDBDocumentClient,
|
|
900
|
+
QueryCommand,
|
|
901
|
+
GetCommand,
|
|
902
|
+
PutCommand,
|
|
903
|
+
UpdateCommand,
|
|
904
|
+
DeleteCommand,
|
|
905
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
906
|
+
import { success, notFound, created, badRequest } from '../lib/response';
|
|
907
|
+
import { parseBody, requirePathParam, getQueryParam, getUserId } from '../lib/request';
|
|
908
|
+
import { contentKeys } from '../lib/keys';
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Content item interface.
|
|
912
|
+
*/
|
|
913
|
+
interface ContentItem {
|
|
914
|
+
id: string;
|
|
915
|
+
slug: string;
|
|
916
|
+
title: string;
|
|
917
|
+
excerpt?: string;
|
|
918
|
+
content: string;
|
|
919
|
+
contentType: string;
|
|
920
|
+
status: 'draft' | 'published';
|
|
921
|
+
authorId: string;
|
|
922
|
+
createdAt: string;
|
|
923
|
+
updatedAt: string;
|
|
924
|
+
publishedAt?: string;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Input for creating new content.
|
|
929
|
+
*/
|
|
930
|
+
interface CreateContentInput {
|
|
931
|
+
slug: string;
|
|
932
|
+
title: string;
|
|
933
|
+
excerpt?: string;
|
|
934
|
+
content: string;
|
|
935
|
+
contentType: string;
|
|
936
|
+
status?: 'draft' | 'published';
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Input for updating existing content.
|
|
941
|
+
*/
|
|
942
|
+
interface UpdateContentInput {
|
|
943
|
+
slug?: string;
|
|
944
|
+
title?: string;
|
|
945
|
+
excerpt?: string;
|
|
946
|
+
content?: string;
|
|
947
|
+
contentType?: string;
|
|
948
|
+
status?: 'draft' | 'published';
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Authenticated event type with Cognito claims.
|
|
953
|
+
*/
|
|
954
|
+
interface AuthenticatedEvent extends APIGatewayProxyEvent {
|
|
955
|
+
requestContext: APIGatewayProxyEvent['requestContext'] & {
|
|
956
|
+
authorizer: {
|
|
957
|
+
claims: { sub: string };
|
|
958
|
+
};
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Initialize DynamoDB client
|
|
963
|
+
const ddbClient = new DynamoDBClient({});
|
|
964
|
+
const client = DynamoDBDocumentClient.from(ddbClient, {
|
|
965
|
+
marshallOptions: {
|
|
966
|
+
removeUndefinedValues: true,
|
|
967
|
+
},
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
const TABLE_NAME = process.env.TABLE_NAME ?? '';
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Map DynamoDB item to ContentItem.
|
|
974
|
+
*/
|
|
975
|
+
function mapToContentItem(item: Record<string, unknown>): ContentItem {
|
|
976
|
+
return {
|
|
977
|
+
id: item.id as string,
|
|
978
|
+
slug: item.slug as string,
|
|
979
|
+
title: item.title as string,
|
|
980
|
+
excerpt: item.excerpt as string | undefined,
|
|
981
|
+
content: item.content as string,
|
|
982
|
+
contentType: item.contentType as string,
|
|
983
|
+
status: item.status as 'draft' | 'published',
|
|
984
|
+
authorId: item.authorId as string,
|
|
985
|
+
createdAt: item.createdAt as string,
|
|
986
|
+
updatedAt: item.updatedAt as string,
|
|
987
|
+
publishedAt: item.publishedAt as string | undefined,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* GET /content - List content items.
|
|
993
|
+
*/
|
|
994
|
+
export const list: APIGatewayProxyHandler = async (event) => {
|
|
995
|
+
const limit = Math.min(parseInt(getQueryParam(event, 'limit', '20') ?? '20', 10), 100);
|
|
996
|
+
const cursor = getQueryParam(event, 'cursor');
|
|
997
|
+
const contentType = getQueryParam(event, 'type') ?? 'blog-post';
|
|
998
|
+
const status = getQueryParam(event, 'status') ?? 'published';
|
|
999
|
+
|
|
1000
|
+
const result = await client.send(
|
|
1001
|
+
new QueryCommand({
|
|
1002
|
+
TableName: TABLE_NAME,
|
|
1003
|
+
IndexName: 'GSI2',
|
|
1004
|
+
KeyConditionExpression: 'GSI2PK = :pk',
|
|
1005
|
+
FilterExpression: '#status = :status',
|
|
1006
|
+
ExpressionAttributeNames: { '#status': 'status' },
|
|
1007
|
+
ExpressionAttributeValues: {
|
|
1008
|
+
':pk': contentType,
|
|
1009
|
+
':status': status,
|
|
1010
|
+
},
|
|
1011
|
+
ScanIndexForward: false,
|
|
1012
|
+
Limit: limit,
|
|
1013
|
+
ExclusiveStartKey: cursor
|
|
1014
|
+
? JSON.parse(Buffer.from(cursor, 'base64').toString())
|
|
1015
|
+
: undefined,
|
|
1016
|
+
})
|
|
1017
|
+
);
|
|
1018
|
+
|
|
1019
|
+
const items = (result.Items ?? []).map(mapToContentItem);
|
|
1020
|
+
|
|
1021
|
+
return success({
|
|
1022
|
+
items,
|
|
1023
|
+
nextCursor: result.LastEvaluatedKey
|
|
1024
|
+
? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64')
|
|
1025
|
+
: undefined,
|
|
1026
|
+
});
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* GET /content/:id - Get content by ID.
|
|
1031
|
+
*/
|
|
1032
|
+
export const get: APIGatewayProxyHandler = async (event) => {
|
|
1033
|
+
const id = requirePathParam(event, 'id');
|
|
1034
|
+
const keys = contentKeys.keys(id);
|
|
1035
|
+
|
|
1036
|
+
const result = await client.send(
|
|
1037
|
+
new GetCommand({
|
|
1038
|
+
TableName: TABLE_NAME,
|
|
1039
|
+
Key: keys,
|
|
1040
|
+
})
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
if (!result.Item) {
|
|
1044
|
+
return notFound(\`Content with id '\${id}' not found\`);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
return success(mapToContentItem(result.Item));
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* GET /content/slug/:slug - Get content by slug.
|
|
1052
|
+
*/
|
|
1053
|
+
export const getBySlug: APIGatewayProxyHandler = async (event) => {
|
|
1054
|
+
const slug = requirePathParam(event, 'slug');
|
|
1055
|
+
const gsi1Keys = contentKeys.gsi1.bySlug(slug);
|
|
1056
|
+
|
|
1057
|
+
const result = await client.send(
|
|
1058
|
+
new QueryCommand({
|
|
1059
|
+
TableName: TABLE_NAME,
|
|
1060
|
+
IndexName: 'GSI1',
|
|
1061
|
+
KeyConditionExpression: 'GSI1PK = :pk AND GSI1SK = :sk',
|
|
1062
|
+
ExpressionAttributeValues: {
|
|
1063
|
+
':pk': gsi1Keys.GSI1PK,
|
|
1064
|
+
':sk': gsi1Keys.GSI1SK,
|
|
1065
|
+
},
|
|
1066
|
+
Limit: 1,
|
|
1067
|
+
})
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
if (!result.Items?.length) {
|
|
1071
|
+
return notFound(\`Content with slug '\${slug}' not found\`);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
return success(mapToContentItem(result.Items[0]));
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* POST /content - Create new content.
|
|
1079
|
+
*/
|
|
1080
|
+
export const create: APIGatewayProxyHandler = async (event) => {
|
|
1081
|
+
const body = parseBody<CreateContentInput>(event);
|
|
1082
|
+
|
|
1083
|
+
if (!body.slug || !body.title || !body.content || !body.contentType) {
|
|
1084
|
+
return badRequest('Missing required fields: slug, title, content, contentType');
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (!/^[a-z0-9-]+$/.test(body.slug)) {
|
|
1088
|
+
return badRequest('Slug must contain only lowercase letters, numbers, and hyphens');
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Check slug uniqueness
|
|
1092
|
+
const gsi1Keys = contentKeys.gsi1.bySlug(body.slug);
|
|
1093
|
+
const existingResult = await client.send(
|
|
1094
|
+
new QueryCommand({
|
|
1095
|
+
TableName: TABLE_NAME,
|
|
1096
|
+
IndexName: 'GSI1',
|
|
1097
|
+
KeyConditionExpression: 'GSI1PK = :pk AND GSI1SK = :sk',
|
|
1098
|
+
ExpressionAttributeValues: {
|
|
1099
|
+
':pk': gsi1Keys.GSI1PK,
|
|
1100
|
+
':sk': gsi1Keys.GSI1SK,
|
|
1101
|
+
},
|
|
1102
|
+
Limit: 1,
|
|
1103
|
+
})
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
if (existingResult.Items?.length) {
|
|
1107
|
+
return badRequest(\`Content with slug '\${body.slug}' already exists\`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const authorId = getUserId(event as AuthenticatedEvent);
|
|
1111
|
+
const now = new Date().toISOString();
|
|
1112
|
+
const id = crypto.randomUUID();
|
|
1113
|
+
const status = body.status ?? 'draft';
|
|
1114
|
+
|
|
1115
|
+
const keys = contentKeys.keys(id);
|
|
1116
|
+
const gsi2Keys = contentKeys.gsi2.byType(body.contentType, now);
|
|
1117
|
+
|
|
1118
|
+
const item = {
|
|
1119
|
+
...keys,
|
|
1120
|
+
...gsi1Keys,
|
|
1121
|
+
...gsi2Keys,
|
|
1122
|
+
id,
|
|
1123
|
+
slug: body.slug,
|
|
1124
|
+
title: body.title,
|
|
1125
|
+
excerpt: body.excerpt,
|
|
1126
|
+
content: body.content,
|
|
1127
|
+
contentType: body.contentType,
|
|
1128
|
+
status,
|
|
1129
|
+
authorId,
|
|
1130
|
+
createdAt: now,
|
|
1131
|
+
updatedAt: now,
|
|
1132
|
+
publishedAt: status === 'published' ? now : undefined,
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
await client.send(
|
|
1136
|
+
new PutCommand({
|
|
1137
|
+
TableName: TABLE_NAME,
|
|
1138
|
+
Item: item,
|
|
1139
|
+
})
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
return created(mapToContentItem(item));
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* PUT /content/:id - Update content.
|
|
1147
|
+
*/
|
|
1148
|
+
export const update: APIGatewayProxyHandler = async (event) => {
|
|
1149
|
+
const id = requirePathParam(event, 'id');
|
|
1150
|
+
const body = parseBody<UpdateContentInput>(event);
|
|
1151
|
+
const keys = contentKeys.keys(id);
|
|
1152
|
+
|
|
1153
|
+
const existingResult = await client.send(
|
|
1154
|
+
new GetCommand({
|
|
1155
|
+
TableName: TABLE_NAME,
|
|
1156
|
+
Key: keys,
|
|
1157
|
+
})
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
if (!existingResult.Item) {
|
|
1161
|
+
return notFound(\`Content with id '\${id}' not found\`);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const existingItem = existingResult.Item;
|
|
1165
|
+
const now = new Date().toISOString();
|
|
1166
|
+
|
|
1167
|
+
// Build update expression
|
|
1168
|
+
const updateExpressionParts: string[] = ['#updatedAt = :updatedAt'];
|
|
1169
|
+
const expressionAttributeNames: Record<string, string> = { '#updatedAt': 'updatedAt' };
|
|
1170
|
+
const expressionAttributeValues: Record<string, unknown> = { ':updatedAt': now };
|
|
1171
|
+
|
|
1172
|
+
if (body.slug && body.slug !== existingItem.slug) {
|
|
1173
|
+
if (!/^[a-z0-9-]+$/.test(body.slug)) {
|
|
1174
|
+
return badRequest('Slug must contain only lowercase letters, numbers, and hyphens');
|
|
1175
|
+
}
|
|
1176
|
+
const newGsi1Keys = contentKeys.gsi1.bySlug(body.slug);
|
|
1177
|
+
updateExpressionParts.push('#slug = :slug', 'GSI1PK = :gsi1pk', 'GSI1SK = :gsi1sk');
|
|
1178
|
+
expressionAttributeNames['#slug'] = 'slug';
|
|
1179
|
+
expressionAttributeValues[':slug'] = body.slug;
|
|
1180
|
+
expressionAttributeValues[':gsi1pk'] = newGsi1Keys.GSI1PK;
|
|
1181
|
+
expressionAttributeValues[':gsi1sk'] = newGsi1Keys.GSI1SK;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (body.title !== undefined) {
|
|
1185
|
+
updateExpressionParts.push('#title = :title');
|
|
1186
|
+
expressionAttributeNames['#title'] = 'title';
|
|
1187
|
+
expressionAttributeValues[':title'] = body.title;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (body.excerpt !== undefined) {
|
|
1191
|
+
updateExpressionParts.push('excerpt = :excerpt');
|
|
1192
|
+
expressionAttributeValues[':excerpt'] = body.excerpt;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (body.content !== undefined) {
|
|
1196
|
+
updateExpressionParts.push('#content = :content');
|
|
1197
|
+
expressionAttributeNames['#content'] = 'content';
|
|
1198
|
+
expressionAttributeValues[':content'] = body.content;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (body.contentType !== undefined) {
|
|
1202
|
+
const newGsi2Keys = contentKeys.gsi2.byType(body.contentType, existingItem.createdAt as string);
|
|
1203
|
+
updateExpressionParts.push('contentType = :contentType', 'GSI2PK = :gsi2pk');
|
|
1204
|
+
expressionAttributeValues[':contentType'] = body.contentType;
|
|
1205
|
+
expressionAttributeValues[':gsi2pk'] = newGsi2Keys.GSI2PK;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (body.status !== undefined) {
|
|
1209
|
+
updateExpressionParts.push('#status = :status');
|
|
1210
|
+
expressionAttributeNames['#status'] = 'status';
|
|
1211
|
+
expressionAttributeValues[':status'] = body.status;
|
|
1212
|
+
|
|
1213
|
+
if (body.status === 'published' && !existingItem.publishedAt) {
|
|
1214
|
+
updateExpressionParts.push('publishedAt = :publishedAt');
|
|
1215
|
+
expressionAttributeValues[':publishedAt'] = now;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const result = await client.send(
|
|
1220
|
+
new UpdateCommand({
|
|
1221
|
+
TableName: TABLE_NAME,
|
|
1222
|
+
Key: keys,
|
|
1223
|
+
UpdateExpression: \`SET \${updateExpressionParts.join(', ')}\`,
|
|
1224
|
+
ExpressionAttributeNames: expressionAttributeNames,
|
|
1225
|
+
ExpressionAttributeValues: expressionAttributeValues,
|
|
1226
|
+
ReturnValues: 'ALL_NEW',
|
|
1227
|
+
})
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
return success(mapToContentItem(result.Attributes!));
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* DELETE /content/:id - Delete content.
|
|
1235
|
+
*/
|
|
1236
|
+
export const remove: APIGatewayProxyHandler = async (event) => {
|
|
1237
|
+
const id = requirePathParam(event, 'id');
|
|
1238
|
+
const keys = contentKeys.keys(id);
|
|
1239
|
+
|
|
1240
|
+
const existingResult = await client.send(
|
|
1241
|
+
new GetCommand({
|
|
1242
|
+
TableName: TABLE_NAME,
|
|
1243
|
+
Key: keys,
|
|
1244
|
+
})
|
|
1245
|
+
);
|
|
1246
|
+
|
|
1247
|
+
if (!existingResult.Item) {
|
|
1248
|
+
return notFound(\`Content with id '\${id}' not found\`);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
await client.send(
|
|
1252
|
+
new DeleteCommand({
|
|
1253
|
+
TableName: TABLE_NAME,
|
|
1254
|
+
Key: keys,
|
|
1255
|
+
})
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
return success({ deleted: true, id });
|
|
1259
|
+
};
|
|
664
1260
|
`;
|
|
665
1261
|
}
|
|
666
1262
|
}
|
package/lib/api-project.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-project.js","sourceRoot":"","sources":["../src/api-project.ts"],"names":[],"mappings":";;;AAAA,mCAA4D;AAI5D,MAAM,EAAE,0BAA0B,EAAE,GAAG,mBAAU,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAa,aAAc,SAAQ,mBAAU,CAAC,iBAAiB;IAC7D;;OAEG;IACa,aAAa,CAAqB;IAElD;;OAEG;IACa,eAAe,CAAU;IAEzC;;OAEG;IACa,cAAc,CAAU;IAExC;;OAEG;IACa,WAAW,CAAU;IAErC;;;;;OAKG;IACH,YAAY,OAA6B;QACvC,4BAA4B;QAC5B,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;YACpB,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAC;QAC5D,CAAC;QACD,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;QAC1D,CAAC;QAED,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,YAAY,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5D,MAAM,WAAW,GAAG,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;QAE9D,KAAK,CAAC;YACJ,IAAI,EAAE,WAAW;YACjB,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,MAAM;YACN,oBAAoB,EAAE,MAAM;YAE5B,sCAAsC;YACtC,QAAQ,EAAE;gBACR,eAAe,EAAE;oBACf,MAAM,EAAE,QAAQ;oBAChB,MAAM,EAAE,UAAU;oBAClB,gBAAgB,EAAE,0BAA0B,CAAC,SAAS;oBACtD,MAAM,EAAE,KAAK;oBACb,OAAO,EAAE,KAAK;oBACd,WAAW,EAAE,IAAI;oBACjB,cAAc,EAAE,IAAI;oBACpB,
|
|
1
|
+
{"version":3,"file":"api-project.js","sourceRoot":"","sources":["../src/api-project.ts"],"names":[],"mappings":"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"}
|