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

Enables strict null checks in SDK #2360

Merged
merged 29 commits into from
Nov 26, 2024
Merged
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
ddd10d1
WIP: Enables strict null checks in SDK
infomiho Oct 28, 2024
90baa94
Update handleApiError type
infomiho Oct 28, 2024
b5c3c63
Throw invalid credentials error explictly
infomiho Oct 28, 2024
7afc4f0
Formatting
infomiho Oct 28, 2024
b4e2362
Update comment and type
infomiho Oct 28, 2024
465856c
Update email env vars
infomiho Oct 28, 2024
5c329a4
Update isHttpErrorWithExtraMessage
infomiho Oct 28, 2024
e04aac3
Update comment
infomiho Oct 28, 2024
1928b6f
Remove TODO
infomiho Oct 28, 2024
e231dda
Fixes jobs types
infomiho Oct 28, 2024
f431153
Update todoApp tests. Comment update.
infomiho Oct 28, 2024
c8b2120
Update e2e tests
infomiho Oct 28, 2024
08304fe
Fixes headless tests
infomiho Oct 28, 2024
a509762
Fixes CORS error
infomiho Oct 28, 2024
4a88a94
Fixes some of the @ts-ignored types
infomiho Nov 21, 2024
7347058
Explicitly encode job data
infomiho Nov 22, 2024
7aaa7b4
Update e2e tests
infomiho Nov 22, 2024
ef74f89
PR comments
infomiho Nov 22, 2024
ccb56b8
Cleanup
infomiho Nov 22, 2024
2e74b57
HttpError type fix
infomiho Nov 22, 2024
0df79f4
Fix ts-ignores in operation hooks
sodic Nov 22, 2024
e4c3443
PR comments
infomiho Nov 25, 2024
6e3737c
PR comments
infomiho Nov 25, 2024
630c107
Fixes new server TS errors
infomiho Nov 25, 2024
815bdf9
Update docs and exports
infomiho Nov 25, 2024
b99952c
Update Changelog
infomiho Nov 25, 2024
a7596ba
Merge branch 'main' into miho-strict-null-checks
infomiho Nov 25, 2024
953fde2
e2e tests
infomiho Nov 25, 2024
e463671
Add complex jobs tests
infomiho Nov 26, 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
11 changes: 10 additions & 1 deletion waspc/ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Next

### ⚠️ Breaking Changes

- Renamed and split `deserializeAndSanitizeProviderData` to `getProviderData` and `getProviderDataWithPassword` so it's more explicit if the resulting data will contain the hashed password or not.

### 🔧 Small improvements

- Enabled strict null checks for the Wasp SDK which means that some of the return types are more precise now e.g. you'll need to check if some value is `null` before using it.

## 0.15.2

### 🐞 Bug fixes
Expand Down Expand Up @@ -79,7 +89,6 @@ To learn more about this feature and how to activate it, check out the [Wasp TS
There are some breaking changes with React Router 6 which will require you to update your code.
Also, the new version of Prisma may cause breaking changes depending on how you're using it.


Read more about breaking changes in the migration guide: https://wasp-lang.dev/docs/migration-guides/migrate-from-0-14-to-0-15 .

### 🐞 Bug fixes
Expand Down
6 changes: 3 additions & 3 deletions waspc/data/Generator/templates/sdk/wasp/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ window.addEventListener('storage', (event) => {
* standard format to be further used by the client. It is also assumed that given API
* error has been formatted as implemented by HttpError on the server.
*/
export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
export function handleApiError<T extends AxiosError<{ message?: string, data?: unknown }>>(error: T): T | WaspHttpError {
if (error?.response) {
// If error came from HTTP response, we capture most informative message
// and also add .statusCode information to it.
Expand All @@ -88,10 +88,10 @@ export function handleApiError(error: AxiosError<{ message?: string, data?: unkn
// That would require copying HttpError code to web-app also and using it here.
const responseJson = error.response?.data
const responseStatusCode = error.response.status
throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
return new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
infomiho marked this conversation as resolved.
Show resolved Hide resolved
} else {
// If any other error, we just propagate it.
throw error
return error
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ export async function login(data: { email: string; password: string }): Promise<
const response = await api.post('{= loginPath =}', data);
await initSession(response.data.sessionId);
} catch (e) {
handleApiError(e);
throw handleApiError(e);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export async function requestPasswordReset(data: { email: string; }): Promise<{
const response = await api.post('{= requestPasswordResetPath =}', data);
return response.data;
} catch (e) {
handleApiError(e);
throw handleApiError(e);
}
}

Expand All @@ -17,6 +17,6 @@ export async function resetPassword(data: { token: string; password: string; }):
const response = await api.post('{= resetPasswordPath =}', data);
return response.data;
} catch (e) {
handleApiError(e);
throw handleApiError(e);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export async function signup(data: { email: string; password: string }): Promise
const response = await api.post('{= signupPath =}', data);
return response.data;
} catch (e) {
handleApiError(e);
throw handleApiError(e);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ export async function verifyEmail(data: {
const response = await api.post('{= verifyEmailPath =}', data)
return response.data
} catch (e) {
handleApiError(e)
throw handleApiError(e)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ function AdditionalFormFields({
}: {
hookForm: UseFormReturn<LoginSignupFormFields>;
formState: FormState;
additionalSignupFields: AdditionalSignupFields;
additionalSignupFields?: AdditionalSignupFields;
}) {
const {
register,
Expand All @@ -293,6 +293,7 @@ function AdditionalFormFields({
Component: any,
props?: React.ComponentProps<ComponentType>
) {
const errorMessage = errors[field.name]?.message;
return (
<FormItemGroup key={field.name}>
<FormLabel>{field.label}</FormLabel>
Expand All @@ -301,8 +302,8 @@ function AdditionalFormFields({
{...props}
disabled={isLoading}
/>
{errors[field.name] && (
<FormError>{errors[field.name].message}</FormError>
{errorMessage && (
<FormError>{errorMessage}</FormError>
)}
</FormItemGroup>
);
Expand Down Expand Up @@ -341,7 +342,7 @@ function isFieldRenderFn(
}

function areAdditionalFieldsRenderFn(
additionalSignupFields: AdditionalSignupFields
additionalSignupFields?: AdditionalSignupFields
): additionalSignupFields is AdditionalSignupFieldRenderFn {
return typeof additionalSignupFields === 'function'
}
2 changes: 1 addition & 1 deletion waspc/data/Generator/templates/sdk/wasp/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ export default async function login(username: string, password: string): Promise

await initSession(response.data.sessionId)
} catch (error) {
handleApiError(error)
throw handleApiError(error)
}
}
4 changes: 2 additions & 2 deletions waspc/data/Generator/templates/sdk/wasp/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type AuthUserData } from '../server/auth/user.js';

import { auth } from "./lucia.js";
import type { Session } from "lucia";
import { throwInvalidCredentialsError } from "./utils.js";
import { createInvalidCredentialsError } from "./utils.js";

import { prisma } from 'wasp/server';
import { createAuthUserData } from "../server/auth/user.js";
Expand Down Expand Up @@ -66,7 +66,7 @@ async function getAuthUserData(userId: {= userEntityUpper =}['id']): Promise<Aut
})

if (!user) {
throwInvalidCredentialsError()
throw createInvalidCredentialsError()
}

return createAuthUserData(user);
Expand Down
2 changes: 1 addition & 1 deletion waspc/data/Generator/templates/sdk/wasp/auth/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ export default async function signup(userFields: { username: string; password: s
try {
await api.post('{= signupPath =}', userFields)
} catch (error) {
handleApiError(error)
throw handleApiError(error)
}
}
2 changes: 1 addition & 1 deletion waspc/data/Generator/templates/sdk/wasp/auth/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function createUserGetter(): Query<void, AuthUser | null> {
if (error.response?.status === 401) {
return null
} else {
handleApiError(error)
throw handleApiError(error)
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions waspc/data/Generator/templates/sdk/wasp/auth/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
AuthUser,
UserEntityWithAuth,
} from '../server/auth/user.js'
import { isNotNull } from '../universal/predicates.js'

/**
* We split the user.ts code into two files to avoid some server-only
Expand Down Expand Up @@ -35,6 +36,7 @@ export type { AuthUserData, AuthUser } from '../server/auth/user.js'
// PRIVATE API (used in SDK and server)
export function makeAuthUserIfPossible(user: null): null
export function makeAuthUserIfPossible(user: AuthUserData): AuthUser
export function makeAuthUserIfPossible(user: AuthUserData | null): AuthUser | null
export function makeAuthUserIfPossible(
user: AuthUserData | null,
): AuthUser | null {
Expand All @@ -45,13 +47,13 @@ function makeAuthUser(data: AuthUserData): AuthUser {
return {
...data,
getFirstProviderUserId: () => {
const identities = Object.values(data.identities).filter(Boolean);
const identities = Object.values(data.identities).filter(isNotNull);
return identities.length > 0 ? identities[0].id : null;
},
};
}

function findUserIdentity(user: UserEntityWithAuth, providerName: ProviderName): UserEntityWithAuth['auth']['identities'][number] | null {
function findUserIdentity(user: UserEntityWithAuth, providerName: ProviderName): NonNullable<UserEntityWithAuth['auth']>['identities'][number] | null {
if (!user.auth) {
return null;
}
Expand Down
61 changes: 43 additions & 18 deletions waspc/data/Generator/templates/sdk/wasp/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export async function updateAuthIdentityProviderData<PN extends ProviderName>(
): Promise<{= authIdentityEntityUpper =}> {
// We are doing the sanitization here only on updates to avoid
// hashing the password multiple times.
const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates);
const sanitizedProviderDataUpdates = await ensurePasswordIsHashed(providerDataUpdates);
const newProviderData = {
...existingProviderData,
...sanitizedProviderDataUpdates,
Expand All @@ -124,25 +124,39 @@ export async function updateAuthIdentityProviderData<PN extends ProviderName>(
});
}

type FindAuthWithUserResult = {= authEntityUpper =} & {
// PRIVATE API
export type FindAuthWithUserResult = {= authEntityUpper =} & {
{= userFieldOnAuthEntityName =}: {= userEntityUpper =}
}

// PRIVATE API
export async function findAuthWithUserBy(
where: Prisma.{= authEntityUpper =}WhereInput
): Promise<FindAuthWithUserResult> {
return prisma.{= authEntityLower =}.findFirst({ where, include: { {= userFieldOnAuthEntityName =}: true }});
): Promise<FindAuthWithUserResult | null> {
const result = await prisma.{= authEntityLower =}.findFirst({ where, include: { {= userFieldOnAuthEntityName =}: true }});

if (result === null) {
return null;
}

if (result.user === null) {
return null;
}

return { ...result, user: result.user };
}

// PUBLIC API
export type CreateUserResult = {= userEntityUpper =} & {
sodic marked this conversation as resolved.
Show resolved Hide resolved
auth: {= authEntityUpper =} | null
}

// PUBLIC API
export async function createUser(
providerId: ProviderId,
serializedProviderData?: string,
userFields?: PossibleUserFields,
): Promise<{= userEntityUpper =} & {
auth: {= authEntityUpper =}
}> {
): Promise<CreateUserResult> {
return prisma.{= userEntityLower =}.create({
data: {
// Using any here to prevent type errors when userFields are not
Expand Down Expand Up @@ -261,34 +275,45 @@ export async function validateAndGetUserFields(
}

// PUBLIC API
export function deserializeAndSanitizeProviderData<PN extends ProviderName>(
export function getProviderData<PN extends ProviderName>(
sodic marked this conversation as resolved.
Show resolved Hide resolved
providerData: string,
): Omit<PossibleProviderData[PN], 'hashedPassword'> {
return sanitizeProviderData(getProviderDataWithPassword(providerData));
}

// PUBLIC API
export function getProviderDataWithPassword<PN extends ProviderName>(
providerData: string,
{ shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {},
): PossibleProviderData[PN] {
// NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON.
let data = JSON.parse(providerData) as PossibleProviderData[PN];
return JSON.parse(providerData);
}

if (providerDataHasPasswordField(data) && shouldRemovePasswordField) {
delete data.hashedPassword;
function sanitizeProviderData<PN extends ProviderName>(
providerData: PossibleProviderData[PN],
): Omit<PossibleProviderData[PN], 'hashedPassword'> {
if (providerDataHasPasswordField(providerData)) {
const { hashedPassword, ...rest } = providerData;
return rest;
} else {
return providerData;
}

return data;
}

// PUBLIC API
export async function sanitizeAndSerializeProviderData<PN extends ProviderName>(
providerData: PossibleProviderData[PN],
): Promise<string> {
return serializeProviderData(
await sanitizeProviderData(providerData)
await ensurePasswordIsHashed(providerData)
);
}

function serializeProviderData<PN extends ProviderName>(providerData: PossibleProviderData[PN]): string {
return JSON.stringify(providerData);
}

async function sanitizeProviderData<PN extends ProviderName>(
async function ensurePasswordIsHashed<PN extends ProviderName>(
sodic marked this conversation as resolved.
Show resolved Hide resolved
providerData: PossibleProviderData[PN],
): Promise<PossibleProviderData[PN]> {
const data = {
Expand All @@ -309,6 +334,6 @@ function providerDataHasPasswordField(
}

// PRIVATE API
export function throwInvalidCredentialsError(message?: string): void {
throw new HttpError(401, 'Invalid credentials', { message })
export function createInvalidCredentialsError(message?: string): HttpError {
return new HttpError(401, 'Invalid credentials', { message })
sodic marked this conversation as resolved.
Show resolved Hide resolved
}
12 changes: 6 additions & 6 deletions waspc/data/Generator/templates/sdk/wasp/auth/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,37 @@ const EMAIL_FIELD = 'email';
const TOKEN_FIELD = 'token';

// PUBLIC API
export function ensureValidEmail(args: unknown): void {
export function ensureValidEmail(args: object): void {
validate(args, [
{ validates: EMAIL_FIELD, message: 'email must be present', validator: email => !!email },
{ validates: EMAIL_FIELD, message: 'email must be a valid email', validator: email => isValidEmail(email) },
]);
}

// PUBLIC API
export function ensureValidUsername(args: unknown): void {
export function ensureValidUsername(args: object): void {
validate(args, [
{ validates: USERNAME_FIELD, message: 'username must be present', validator: username => !!username }
]);
}

// PUBLIC API
export function ensurePasswordIsPresent(args: unknown): void {
export function ensurePasswordIsPresent(args: object): void {
validate(args, [
{ validates: PASSWORD_FIELD, message: 'password must be present', validator: password => !!password },
]);
}

// PUBLIC API
export function ensureValidPassword(args: unknown): void {
export function ensureValidPassword(args: object): void {
validate(args, [
{ validates: PASSWORD_FIELD, message: 'password must be at least 8 characters', validator: password => isMinLength(password, 8) },
{ validates: PASSWORD_FIELD, message: 'password must contain a number', validator: password => containsNumber(password) },
]);
}

// PUBLIC API
export function ensureTokenIsPresent(args: unknown): void {
export function ensureTokenIsPresent(args: object): void {
validate(args, [
{ validates: TOKEN_FIELD, message: 'token must be present', validator: token => !!token },
]);
Expand All @@ -47,7 +47,7 @@ export function throwValidationError(message: string): void {
throw new HttpError(422, 'Validation failed', { message })
}

function validate(args: unknown, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void {
function validate(args: object, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void {
for (const { validates, message, validator } of validators) {
if (!validator(args[validates])) {
throwValidationError(message);
Expand Down
2 changes: 1 addition & 1 deletion waspc/data/Generator/templates/sdk/wasp/client/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{{={= =}=}}
import { stripTrailingSlash } from 'wasp/universal/url'
import { stripTrailingSlash } from '../universal/url.js'

const apiUrl = stripTrailingSlash(import.meta.env.REACT_APP_API_URL) || '{= defaultServerUrl =}';

Expand Down
Loading
Loading