Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

store layouts in s3 #328

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d456231
define abstract and inMem class
dblatcher Aug 30, 2024
d6dc1b5
add get routes to api
dblatcher Aug 30, 2024
1867f76
basic views on the UI
dblatcher Aug 30, 2024
389f880
load newsletters with the layouts
dblatcher Aug 30, 2024
a07e3cb
display component
dblatcher Aug 30, 2024
3a81234
add USE_IN_MEMORY_LAYOUT_STORAGE setting
dblatcher Sep 6, 2024
b14d329
add a local option to disable the new UI route
dblatcher Sep 6, 2024
2043ae9
make the layout index page useful
dblatcher Sep 6, 2024
30cb35c
Merge branch 'main' into dblatcher/store-layouts
dblatcher Sep 16, 2024
43df13d
correct zod typing and support array data
dblatcher Sep 16, 2024
9db118d
add post route for layouts
dblatcher Sep 16, 2024
577f40c
can create new layouts from the map page
dblatcher Sep 16, 2024
b05e9e8
layout page has json editor for layouts
dblatcher Sep 16, 2024
62eff4a
use a fixture json file for the initial local test data
dblatcher Sep 27, 2024
ee33926
move components to subfolder, make the cards nicer
dblatcher Sep 27, 2024
c27342d
move display logic out of the View component
dblatcher Sep 27, 2024
3d072ae
address possible falsy editionId
dblatcher Sep 27, 2024
0a72c7f
don't show the edit options to people without permisson
dblatcher Sep 27, 2024
eff345a
add new permissions for edit layouts
dblatcher Sep 27, 2024
566fb38
add layout routes to UI server
dblatcher Oct 11, 2024
9b07c4e
sketch out the class defn
dblatcher Oct 25, 2024
4a6569d
use the s3 storage based on settings
dblatcher Oct 25, 2024
e8ca86c
read and create work
dblatcher Oct 25, 2024
f54e805
updating works
dblatcher Oct 25, 2024
27b014e
add delete method
dblatcher Oct 25, 2024
20b2fef
fix format for put object commands
dblatcher Oct 25, 2024
275aac5
Merge branch 'main' into dblatcher/store-layouts-in-s3
dblatcher Oct 28, 2024
49e7ed2
add new routes to cache control
dblatcher Oct 28, 2024
2e8110c
lint - import style
dblatcher Oct 28, 2024
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
3 changes: 3 additions & 0 deletions apps/newsletters-api/env.local.example.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
# To use s3 locally (overriding the s3 values below, set to true)
USE_IN_MEMORY_STORAGE=false

# To the local in-memory version of the layout storage instead of using the s3 bucket
USE_IN_MEMORY_LAYOUT_STORAGE=true

# the name of the s3 bucket holding the data. (avoid committing actual bucket names in the repo)
NEWSLETTER_BUCKET_NAME=your-bucket-name

Expand Down
31 changes: 25 additions & 6 deletions apps/newsletters-api/src/apiDeploymentSettings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
isServingReadWriteEndpoints,
isServingUI,
isUndefinedAndNotProduction,
isUsingInMemoryStorage,
isUsingInMemoryLayoutStorage,
isUsingInMemoryNewsletterStorage,
} from './apiDeploymentSettings';

const ORIGINAL_ENV = process.env;
Expand Down Expand Up @@ -148,20 +149,38 @@ describe('isServingReadWriteEndpoints', () => {
});
});

describe('isUsingInMemoryStorage', () => {
describe('isUsingInMemoryNewsletterStorage', () => {
it('returns false where USE_IN_MEMORY_STORAGE is not set', () => {
expect(isUsingInMemoryStorage()).toBe(false);
expect(isUsingInMemoryNewsletterStorage()).toBe(false);
});
it('returns false where USE_IN_MEMORY_STORAGE is false', () => {
process.env.USE_IN_MEMORY_STORAGE = 'false';
expect(isUsingInMemoryStorage()).toBe(false);
expect(isUsingInMemoryNewsletterStorage()).toBe(false);
});
it('returns true where USE_IN_MEMORY_STORAGE is true', () => {
process.env.USE_IN_MEMORY_STORAGE = 'true';
expect(isUsingInMemoryStorage()).toBe(true);
expect(isUsingInMemoryNewsletterStorage()).toBe(true);
});
it('returns false if USE_IN_MEMORY_STORAGE is something other than true or false', () => {
process.env.USE_IN_MEMORY_STORAGE = 'foo';
expect(isUsingInMemoryStorage()).toBe(false);
expect(isUsingInMemoryNewsletterStorage()).toBe(false);
});
});

describe(isUsingInMemoryLayoutStorage.name, () => {
it('returns false where USE_IN_MEMORY_LAYOUT_STORAGE is not set', () => {
expect(isUsingInMemoryLayoutStorage()).toBe(false);
});
it('returns false where USE_IN_MEMORY_LAYOUT_STORAGE is false', () => {
process.env.USE_IN_MEMORY_LAYOUT_STORAGE = 'false';
expect(isUsingInMemoryLayoutStorage()).toBe(false);
});
it('returns true where USE_IN_MEMORY_LAYOUT_STORAGE is true', () => {
process.env.USE_IN_MEMORY_LAYOUT_STORAGE = 'true';
expect(isUsingInMemoryLayoutStorage()).toBe(true);
});
it('returns false if USE_IN_MEMORY_LAYOUT_STORAGE is something other than true or false', () => {
process.env.USE_IN_MEMORY_LAYOUT_STORAGE = 'foo';
expect(isUsingInMemoryLayoutStorage()).toBe(false);
});
});
5 changes: 4 additions & 1 deletion apps/newsletters-api/src/apiDeploymentSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,12 @@ export const isServingReadEndpoints = () => {
return undefinedAndNotProduction || isApiRead || isApiReadWrite;
};

export const isUsingInMemoryStorage = () =>
export const isUsingInMemoryNewsletterStorage = () =>
process.env.USE_IN_MEMORY_STORAGE === 'true';

export const isUsingInMemoryLayoutStorage = () =>
process.env.USE_IN_MEMORY_LAYOUT_STORAGE === 'true';

export const getTestJwtProfileDataIfUsing = () => {
return process.env.USE_FAKE_JWT === 'true' ? process.env.FAKE_JWT : undefined;
};
Expand Down
3 changes: 3 additions & 0 deletions apps/newsletters-api/src/app/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const getCacheControl = (
if (req.routerPath.startsWith('/api/legacy')) {
return newsletterTtl;
}
if (req.routerPath.startsWith('/api/layouts')) {
return newsletterTtl;
}

return undefined;
};
Expand Down
92 changes: 92 additions & 0 deletions apps/newsletters-api/src/app/routes/layouts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { FastifyInstance } from 'fastify';
import {
editionIdSchema,
layoutSchema,
} from '@newsletters-nx/newsletters-data-client';
import { permissionService } from '../../services/permissions';
import { layoutStore } from '../../services/storage';
import { getUserProfile } from '../get-user-profile';
import {
makeErrorResponse,
makeSuccessResponse,
mapStorageFailureReasonToStatusCode,
} from '../responses';

export function registerReadLayoutRoutes(app: FastifyInstance) {
app.get('/api/layouts', async (req, res) => {
const storageResponse = await layoutStore.readAll();
if (!storageResponse.ok) {
return res
.status(mapStorageFailureReasonToStatusCode(storageResponse.reason))
.send(makeErrorResponse(storageResponse.message));
}
return makeSuccessResponse(storageResponse.data);
});

app.get<{
Params: { editionId: string };
}>('/api/layouts/:editionId', async (req, res) => {
const { editionId } = req.params;

const idParseResult = editionIdSchema.safeParse(editionId.toUpperCase());

if (!idParseResult.success) {
return res
.status(400)
.send(makeErrorResponse(`No such edition ${editionId}`));
}

const storageResponse = await layoutStore.read(idParseResult.data);

if (!storageResponse.ok) {
return res
.status(mapStorageFailureReasonToStatusCode(storageResponse.reason))
.send(makeErrorResponse(storageResponse.message));
}
return makeSuccessResponse(storageResponse.data);
});
}

export function registerWriteLayoutRoutes(app: FastifyInstance) {
app.post<{
Body: unknown;
Params: { editionId: string };
}>('/api/layouts/:editionId', async (req, res) => {
const { editionId } = req.params;
const layout: unknown = req.body;
const user = getUserProfile(req);
const permissions = await permissionService.get(user.profile);

if (!permissions.editLayouts) {
return res
.status(403)
.send(makeErrorResponse(`You don't have permission to edit layouts.`));
}

const idParseResult = editionIdSchema.safeParse(editionId.toUpperCase());
if (!idParseResult.success) {
return res
.status(400)
.send(makeErrorResponse(`No such edition ${editionId}`));
}

const layoutParseResult = layoutSchema.safeParse(layout);
if (!layoutParseResult.success) {
return res.status(400).send(makeErrorResponse(`invalid layout data`));
}

// TO DO - need a separate route or param for 'create' requests
// that will fail if the layout exists already rather than replacing with blanks
const storageResponse = await layoutStore.update(
idParseResult.data,
layoutParseResult.data,
);

if (!storageResponse.ok) {
return res
.status(mapStorageFailureReasonToStatusCode(storageResponse.reason))
.send(makeErrorResponse(storageResponse.message));
}
return makeSuccessResponse(storageResponse.data);
});
}
6 changes: 6 additions & 0 deletions apps/newsletters-api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { setHeaderHook } from './app/headers';
import { registerCurrentStepRoute } from './app/routes/currentStep';
import { registerDraftsRoutes } from './app/routes/drafts';
import { registerHealthRoute } from './app/routes/health';
import {
registerReadLayoutRoutes,
registerWriteLayoutRoutes,
} from './app/routes/layouts';
import {
registerReadNewsletterRoutes,
registerReadWriteNewsletterRoutes,
Expand All @@ -27,11 +31,13 @@ if (isServingReadWriteEndpoints()) {
registerUserRoute(app);
registerReadWriteNewsletterRoutes(app);
registerNotificationRoutes(app);
registerWriteLayoutRoutes(app);
}
if (isServingReadEndpoints()) {
registerReadNewsletterRoutes(app);
registerDraftsRoutes(app);
registerRenderingTemplatesRoutes(app);
registerReadLayoutRoutes(app);
}

app.addHook('onSend', setHeaderHook);
Expand Down
2 changes: 2 additions & 0 deletions apps/newsletters-api/src/register-ui-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ export function registerUIServer(app: FastifyInstance) {
app.get('/launched', handleUiRequest);
app.get('/templates/*', handleUiRequest);
app.get('/templates', handleUiRequest);
app.get('/layouts/*', handleUiRequest);
Dismissed Show dismissed Hide dismissed
app.get('/layouts', handleUiRequest);
Dismissed Show dismissed Hide dismissed
}
23 changes: 19 additions & 4 deletions apps/newsletters-api/src/services/storage/index.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,56 @@
import type { SESClient } from '@aws-sdk/client-ses';
import type {
DraftStorage,
EditionsLayouts,
EmailEnvInfo,
LayoutStorage,
NewsletterData,
NewsletterStorage,
UserProfile,
} from '@newsletters-nx/newsletters-data-client';
import {
DraftService,
InMemoryLayoutStorage,
InMemoryNewsletterStorage,
isNewsletterData,
LaunchService,
} from '@newsletters-nx/newsletters-data-client';
import layoutsData from '../../../static/layouts.local.json';
import newslettersData from '../../../static/newsletters.local.json';
import { isUsingInMemoryStorage } from '../../apiDeploymentSettings';
import {
isUsingInMemoryLayoutStorage,
isUsingInMemoryNewsletterStorage,
} from '../../apiDeploymentSettings';
import { makeEmailEnvInfo } from '../notifications/email-env';
import { makeSesClient } from '../notifications/email-service';
import { makeInMemoryStorageInstance } from './inMemoryStorageInstance';
import {
getS3LayoutStore,
getS3NewsletterStore,
makeS3DraftStorageInstance,
} from './s3StorageInstance';

const isUsingInMemoryStore = isUsingInMemoryStorage();
const isUsingInMemoryNewslettersStore = isUsingInMemoryNewsletterStorage();

const draftStore: DraftStorage = isUsingInMemoryStore
const draftStore: DraftStorage = isUsingInMemoryNewslettersStore
? makeInMemoryStorageInstance()
: makeS3DraftStorageInstance();

const validNewsletters = newslettersData.filter((item) =>
isNewsletterData(item),
);
const newsletterStore: NewsletterStorage = isUsingInMemoryStore
const newsletterStore: NewsletterStorage = isUsingInMemoryNewslettersStore
? new InMemoryNewsletterStorage(
validNewsletters as unknown as NewsletterData[],
)
: getS3NewsletterStore();

const isUsingInMemoryLayoutStore = isUsingInMemoryLayoutStorage();

const layoutStore: LayoutStorage = isUsingInMemoryLayoutStore
? new InMemoryLayoutStorage(layoutsData as unknown as EditionsLayouts)
: getS3LayoutStore();

const makelaunchServiceForUser = (userProfile: UserProfile) =>
new LaunchService(
draftStore,
Expand All @@ -57,4 +71,5 @@ export {
makeDraftServiceForUser,
makelaunchServiceForUser,
newsletterStore,
layoutStore,
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
S3DraftStorage,
S3LayoutStorage,
S3NewsletterStorage,
} from '@newsletters-nx/newsletters-data-client';
import { getS3Client } from './s3-client-factory';
Expand All @@ -21,4 +22,8 @@ const getS3NewsletterStore = (): S3NewsletterStorage => {
return new S3NewsletterStorage(getS3BucketName(), getS3Client());
};

export { makeS3DraftStorageInstance, getS3NewsletterStore };
const getS3LayoutStore = (): S3LayoutStorage => {
return new S3LayoutStorage(getS3BucketName(), getS3Client());
};

export { makeS3DraftStorageInstance, getS3NewsletterStore, getS3LayoutStore };
23 changes: 23 additions & 0 deletions apps/newsletters-api/static/layouts.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"UK": [
{
"title": "Get started",
"newsletters": [
"bmx-tesla",
"roi-female",
"electric-bicycle",
"dram-security",
"does-not-exist"
]
},
{
"title": "In depth",
"newsletters": [
"muddle-teal",
"van-coordinator",
"does-not-exist-two",
"dram-security"
]
}
]
}
10 changes: 7 additions & 3 deletions apps/newsletters-ui/src/app/components/Illustration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import { Stack, Typography } from '@mui/material';
interface Props {
name: string;
url?: string;
height?: number;
}

export const Illustration = ({ name, url }: Props) => {
export const Illustration = ({ name, url, height = 200 }: Props) => {
const image = url ? (
<img src={url} alt="" height={200} />
<img src={url} alt="" height={height} />
) : (
<ImageNotSupportedIcon color="primary" sx={{ height: 200, width: 200 }} />
<ImageNotSupportedIcon
color="primary"
sx={{ height: height, width: height }}
/>
);

const captionText = url
Expand Down
28 changes: 10 additions & 18 deletions apps/newsletters-ui/src/app/components/JsonEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,19 @@ import {
TextField,
} from '@mui/material';
import { useEffect, useState } from 'react';
import type { z, ZodIssue, ZodObject, ZodRawShape } from 'zod';
import type { Schema, ZodIssue } from 'zod';
import { ZodIssuesReport } from './ZodIssuesReport';

type JsonRecord = Record<string, unknown>;
type JsonRecordOrArray = Record<string, unknown> | unknown[];

interface Props<T extends ZodRawShape> {
originalData: SchemaObjectType<T>;
schema: ZodObject<T>;
submit: { (data: SchemaObjectType<T>): void | Promise<void> };
interface Props<T extends JsonRecordOrArray> {
originalData: T;
schema: Schema<T>;
submit: { (data: T): void | Promise<void> };
}

type SchemaObjectType<T extends z.ZodRawShape> = {
[k in keyof z.objectUtil.addQuestionMarks<{
[k in keyof T]: T[k]['_output'];
}>]: z.objectUtil.addQuestionMarks<{
[k in keyof T]: T[k]['_output'];
}>[k];
};

const getFormattedJsonString = (
data: JsonRecord,
data: JsonRecordOrArray,
): { ok: true; json: string } | { ok: false } => {
try {
const json = JSON.stringify(data, undefined, 4);
Expand All @@ -39,9 +31,9 @@ const getFormattedJsonString = (
}
};

const safeJsonParse = (value: string): JsonRecord | undefined => {
const safeJsonParse = (value: string): JsonRecordOrArray | undefined => {
try {
return JSON.parse(value) as JsonRecord;
return JSON.parse(value) as JsonRecordOrArray;
} catch (err) {
return undefined;
}
Expand Down Expand Up @@ -79,7 +71,7 @@ const CheckResultMessage = (props: {
);
};

export const JsonEditor = <T extends ZodRawShape>({
export const JsonEditor = <T extends JsonRecordOrArray>({
originalData,
schema,
submit,
Expand Down
Loading
Loading