diff --git a/packages/api-v4/.changeset/pr-9390-added-1689111404354.md b/packages/api-v4/.changeset/pr-9390-added-1689111404354.md new file mode 100644 index 00000000000..5f0c1659af1 --- /dev/null +++ b/packages/api-v4/.changeset/pr-9390-added-1689111404354.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +Endpoints for VPC Subnets ([#9390](https://github.com/linode/manager/pull/9390)) diff --git a/packages/api-v4/src/vpcs/types.ts b/packages/api-v4/src/vpcs/types.ts index ca4bc1dcf5f..789c4b39776 100644 --- a/packages/api-v4/src/vpcs/types.ts +++ b/packages/api-v4/src/vpcs/types.ts @@ -12,7 +12,7 @@ export interface CreateVPCPayload { label: string; description?: string; region: string; - subnets?: SubnetPostObject[]; + subnets?: CreateSubnetPayload[]; } export interface UpdateVPCPayload { @@ -20,15 +20,19 @@ export interface UpdateVPCPayload { description?: string; } -export interface SubnetPostObject { +export interface CreateSubnetPayload { label: string; - ipv4: string; - ipv6: string; + ipv4?: string; + ipv6?: string; } -export interface Subnet extends SubnetPostObject { +export interface Subnet extends CreateSubnetPayload { id: number; linodes: number[]; created: string; updated: string; } + +export interface ModifySubnetPayload { + label: string; +} diff --git a/packages/api-v4/src/vpcs/vpcs.ts b/packages/api-v4/src/vpcs/vpcs.ts index 371bdd1e156..2fefb1471f7 100644 --- a/packages/api-v4/src/vpcs/vpcs.ts +++ b/packages/api-v4/src/vpcs/vpcs.ts @@ -1,5 +1,7 @@ import { + createSubnetSchema, createVPCSchema, + modifySubnetSchema, updateVPCSchema, } from '@linode/validation/lib/vpcs.schema'; import { BETA_API_ROOT as API_ROOT } from '../constants'; @@ -11,7 +13,14 @@ import Request, { setXFilter, } from '../request'; import { Filter, ResourcePage as Page, Params } from '../types'; -import { CreateVPCPayload, UpdateVPCPayload, VPC } from './types'; +import { + CreateSubnetPayload, + CreateVPCPayload, + ModifySubnetPayload, + Subnet, + UpdateVPCPayload, + VPC, +} from './types'; // VPC methods /** @@ -79,3 +88,82 @@ export const deleteVPC = (vpcID: number) => ); // Subnet methods +/** + * getSubnets + * + * Return a paginated list of subnets under a specified VPC. + * + */ +export const getSubnets = (vpcID: number, params?: Params, filter?: Filter) => + Request<Page<Subnet>>( + setURL(`${API_ROOT}/vpcs/${encodeURIComponent(vpcID)}/subnets`), + setMethod('GET'), + setParams(params), + setXFilter(filter) + ); + +/** + * getSubnet + * + * Return details for a single specified subnet under a specified VPC. + * + */ +export const getSubnet = (vpcID: number, subnetID: number) => + Request<Subnet>( + setURL( + `${API_ROOT}/vpcs/${encodeURIComponent( + vpcID + )}/subnets/${encodeURIComponent(subnetID)}` + ), + setMethod('GET') + ); + +/** + * createSubnet + * + * Create a new subnet under an existing VPC. + * + */ +export const createSubnet = (vpcID: number, data: CreateSubnetPayload) => + Request<Subnet>( + setURL(`${API_ROOT}/vpcs/${encodeURIComponent(vpcID)}/subnets`), + setMethod('POST'), + setData(data, createSubnetSchema) + ); + +/** + * modifySubnet + * + * Modify an existing subnet. + * + */ +export const modifySubnet = ( + vpcID: number, + subnetID: number, + data: ModifySubnetPayload +) => + Request<Subnet>( + setURL( + `${API_ROOT}/vpcs/${encodeURIComponent( + vpcID + )}/subnets/${encodeURIComponent(subnetID)}` + ), + setMethod('PUT'), + setData(data, modifySubnetSchema) + ); + +/** + * deleteSubnet + * + * Delete a single specified subnet belonging to a specified VPC. + * + */ +export const deleteSubnet = (vpcID: number, subnetID: number) => + Request<{}>( + setURL( + `${API_ROOT}/vpcs/${encodeURIComponent( + vpcID + )}/subnets/${encodeURIComponent(subnetID)}` + ), + setMethod('DELETE') + ); diff --git a/packages/manager/.changeset/pr-9390-tech-stories-1689111377085.md b/packages/manager/.changeset/pr-9390-tech-stories-1689111377085.md new file mode 100644 index 00000000000..53dc591d4cf --- /dev/null +++ b/packages/manager/.changeset/pr-9390-tech-stories-1689111377085.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +React Query queries for VPC Subnets ([#9390](https://github.com/linode/manager/pull/9390)) diff --git a/packages/manager/src/queries/vpcs.ts b/packages/manager/src/queries/vpcs.ts index 7e1c8a51c5d..e61f1718bbd 100644 --- a/packages/manager/src/queries/vpcs.ts +++ b/packages/manager/src/queries/vpcs.ts @@ -1,11 +1,19 @@ import { + CreateSubnetPayload, CreateVPCPayload, + ModifySubnetPayload, + Subnet, UpdateVPCPayload, VPC, + createSubnet, createVPC, + deleteSubnet, deleteVPC, + getSubnet, + getSubnets, getVPC, getVPCs, + modifySubnet, updateVPC, } from '@linode/api-v4'; import { @@ -16,26 +24,28 @@ import { } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from 'react-query'; -export const queryKey = 'vpcs'; +export const vpcQueryKey = 'vpcs'; +export const subnetQueryKey = 'subnets'; +// VPC queries export const useVPCsQuery = (params: Params, filter: Filter) => { return useQuery<ResourcePage<VPC>, APIError[]>( - [queryKey, 'paginated', params, filter], + [vpcQueryKey, 'paginated', params, filter], () => getVPCs(params, filter), { keepPreviousData: true } ); }; export const useVPCQuery = (id: number) => { - return useQuery<VPC, APIError[]>([queryKey, 'vpc', id], () => getVPC(id)); + return useQuery<VPC, APIError[]>([vpcQueryKey, 'vpc', id], () => getVPC(id)); }; export const useCreateVPCMutation = () => { const queryClient = useQueryClient(); return useMutation<VPC, APIError[], CreateVPCPayload>(createVPC, { onSuccess: (VPC) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData([queryKey, 'vpc', VPC.id], VPC); + queryClient.invalidateQueries([vpcQueryKey, 'paginated']); + queryClient.setQueryData([vpcQueryKey, 'vpc', VPC.id], VPC); }, }); }; @@ -46,8 +56,8 @@ export const useUpdateVPCMutation = (id: number) => { (data) => updateVPC(id, data), { onSuccess: (VPC) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData<VPC>([queryKey, 'vpc', VPC.id], VPC); + queryClient.invalidateQueries([vpcQueryKey, 'paginated']); + queryClient.setQueryData<VPC>([vpcQueryKey, 'vpc', VPC.id], VPC); }, } ); @@ -57,8 +67,101 @@ export const useDeleteVPCMutation = (id: number) => { const queryClient = useQueryClient(); return useMutation<{}, APIError[]>(() => deleteVPC(id), { onSuccess: () => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.removeQueries([queryKey, 'vpc', id]); + queryClient.invalidateQueries([vpcQueryKey, 'paginated']); + queryClient.removeQueries([vpcQueryKey, 'vpc', id]); + }, + }); +}; + +// Subnet queries +export const useSubnetsQuery = ( + vpcID: number, + params: Params, + filter: Filter +) => { + return useQuery<ResourcePage<Subnet>, APIError[]>( + [vpcQueryKey, 'vpc', vpcID, subnetQueryKey, 'paginated', params, filter], + () => getSubnets(vpcID, params, filter), + { keepPreviousData: true } + ); +}; + +export const useSubnetQuery = (vpcID: number, subnetID: number) => { + return useQuery<Subnet, APIError[]>( + [vpcQueryKey, 'vpc', vpcID, subnetQueryKey, 'subnet', subnetID], + () => getSubnet(vpcID, subnetID) + ); +}; + +export const useCreateSubnetMutation = (vpcID: number) => { + const queryClient = useQueryClient(); + return useMutation<Subnet, APIError[], CreateSubnetPayload>( + (data) => createSubnet(vpcID, data), + { + onSuccess: () => { + // New subnet created --> refresh the paginated and individual VPC queries, plus the /subnets VPC query + queryClient.invalidateQueries([vpcQueryKey, 'paginated']); + queryClient.invalidateQueries([vpcQueryKey, 'vpc', vpcID]); + queryClient.invalidateQueries([ + vpcQueryKey, + 'vpc', + vpcID, + subnetQueryKey, + ]); + }, + } + ); +}; + +export const useUpdateSubnetMutation = (vpcID: number, subnetID: number) => { + const queryClient = useQueryClient(); + return useMutation<Subnet, APIError[], ModifySubnetPayload>( + (data) => modifySubnet(vpcID, subnetID, data), + { + onSuccess: () => { + // Subnet modified --> refresh the paginated and individual VPC queries, plus the paginated & individual subnet queries + queryClient.invalidateQueries([vpcQueryKey, 'paginated']); + queryClient.invalidateQueries([vpcQueryKey, 'vpc', vpcID]); + queryClient.invalidateQueries([ + vpcQueryKey, + 'vpc', + vpcID, + subnetQueryKey, + ]); + queryClient.invalidateQueries([ + vpcQueryKey, + 'vpc', + vpcID, + subnetQueryKey, + 'subnet', + subnetID, + ]); + }, + } + ); +}; + +export const useDeleteSubnetMutation = (vpcID: number, subnetID: number) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>(() => deleteSubnet(vpcID, subnetID), { + onSuccess: () => { + // Subnet deleted --> refresh the paginated and individual VPC queries, plus the paginated subnet query, & clear the individual subnet query + queryClient.invalidateQueries([vpcQueryKey, 'paginated']); + queryClient.invalidateQueries([vpcQueryKey, 'vpc', vpcID]); + queryClient.invalidateQueries([ + vpcQueryKey, + 'vpc', + vpcID, + subnetQueryKey, + ]); + queryClient.removeQueries([ + vpcQueryKey, + 'vpc', + vpcID, + subnetQueryKey, + 'subnet', + subnetID, + ]); }, }); }; diff --git a/packages/validation/.changeset/pr-9390-added-1689111447323.md b/packages/validation/.changeset/pr-9390-added-1689111447323.md new file mode 100644 index 00000000000..19d6a550b3a --- /dev/null +++ b/packages/validation/.changeset/pr-9390-added-1689111447323.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +Validation for VPC subnet creation and modifications ([#9390](https://github.com/linode/manager/pull/9390)) diff --git a/packages/validation/src/vpcs.schema.ts b/packages/validation/src/vpcs.schema.ts index cc0d2180c11..3d9e5b98a4e 100644 --- a/packages/validation/src/vpcs.schema.ts +++ b/packages/validation/src/vpcs.schema.ts @@ -1,39 +1,93 @@ +import ipaddr from 'ipaddr.js'; import { array, object, string } from 'yup'; const LABEL_MESSAGE = 'VPC label must be between 1 and 64 characters.'; +const LABEL_REQUIRED = 'Label is required'; +const LABEL_REQUIREMENTS = + 'Must include only ASCII letters, numbers, and dashes'; + +const labelTestDetails = { + testName: 'no two dashes in a row', + testMessage: 'Must not contain two dashes in a row', +}; + +const validateIP = (value?: string): boolean => { + if (!value) { + return false; + } + + try { + const addr = ipaddr.parse(value); + const type = addr.kind(); + + if (type === 'ipv4') { + ipaddr.IPv4.isValid(value); + ipaddr.IPv4.parseCIDR(value); + } else if (type === 'ipv6') { + ipaddr.IPv6.parseCIDR(value); + } else { + return false; + } + + return true; + } catch (err) { + return false; + } +}; + +const labelValidation = string() + .test( + labelTestDetails.testName, + labelTestDetails.testMessage, + (value) => !value?.includes('--') + ) + .min(1, LABEL_MESSAGE) + .max(64, LABEL_MESSAGE) + .matches(/[a-zA-Z0-9-]+/, LABEL_REQUIREMENTS); export const createVPCSchema = object({ - label: string() - .test( - 'no two dashes in a row', - 'Must not contain two dashes in a row', - (value) => !value?.includes('--') - ) - .required('Label is required') - .min(1, LABEL_MESSAGE) - .max(64, LABEL_MESSAGE) - .matches( - /[a-zA-Z0-9-]+/, - 'Must include only ASCII letters, numbers, and dashes' - ), + label: labelValidation.required(LABEL_REQUIRED), description: string(), region: string().required('Region is required'), subnets: array().of(object()), }); export const updateVPCSchema = object({ - label: string() - .notRequired() - .test( - 'no two dashes in a row', - 'Must not contain two dashes in a row', - (value) => !value?.includes('--') - ) - .min(1, LABEL_MESSAGE) - .max(64, LABEL_MESSAGE) - .matches( - /[a-zA-Z0-9-]+/, - 'Must include only ASCII letters, numbers, and dashes' - ), + label: labelValidation.notRequired(), description: string().notRequired(), }); + +export const createSubnetSchema = object().shape( + { + label: labelValidation.required(LABEL_REQUIRED), + ipv4: string() + .test({ + name: 'cidr', + message: 'The IPv4 range must be in CIDR format', + test: validateIP, + }) + .notRequired() + .ensure() + .when('ipv6', { + is: '', + then: string().required(), + }), + ipv6: string() + .test({ + name: 'cidr mask', + message: 'Must be the subnet mask of the IP, e.g. /24', + test: validateIP, + }) + .notRequired() + .ensure() + .when('ipv4', { + is: '', + then: string().required(), + }), + }, + [['ipv6', 'ipv4']] +); + +export const modifySubnetSchema = object({ + label: labelValidation.required(LABEL_REQUIRED), +});